From bd7018e2495f95d6335607f0ef7a5fc9c4f0314a Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Wed, 29 Jul 2026 14:44:47 +0300 Subject: [PATCH 1/2] STX-211 Personal access tokens CLI --- .../streamx/cli/commands/StreamxCommand.java | 4 +- .../cli/commands/auth/AuthCommand.java | 4 +- .../cli/commands/auth/token/TokenCommand.java | 26 +++ .../auth/token/create/CreateCommand.java | 43 ++++ .../commands/auth/token/list/ListCommand.java | 66 ++++++ .../auth/token/revoke/RevokeCommand.java | 36 +++ .../completion/CompleteTokenIdsCommand.java | 35 +++ .../completion/ZshCompletionGenerator.java | 4 + .../cli/platform/ProfileTokensApi.java | 31 +++ .../platform/TokenIdCompletionCandidates.java | 11 + .../commands/auth/token/StubTokensServer.java | 118 ++++++++++ .../commands/auth/token/TokenCommandIT.java | 207 ++++++++++++++++++ 12 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/streamx/cli/commands/auth/token/TokenCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/auth/token/create/CreateCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/auth/token/list/ListCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/auth/token/revoke/RevokeCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/completion/CompleteTokenIdsCommand.java create mode 100644 src/main/java/com/streamx/cli/platform/ProfileTokensApi.java create mode 100644 src/main/java/com/streamx/cli/platform/TokenIdCompletionCandidates.java create mode 100644 src/test/java/com/streamx/cli/commands/auth/token/StubTokensServer.java create mode 100644 src/test/java/com/streamx/cli/commands/auth/token/TokenCommandIT.java diff --git a/src/main/java/com/streamx/cli/commands/StreamxCommand.java b/src/main/java/com/streamx/cli/commands/StreamxCommand.java index 49c89dbf..f82f544b 100644 --- a/src/main/java/com/streamx/cli/commands/StreamxCommand.java +++ b/src/main/java/com/streamx/cli/commands/StreamxCommand.java @@ -7,6 +7,7 @@ import com.streamx.cli.commands.completion.CompleteSettingsKeysCommand; import com.streamx.cli.commands.completion.CompleteSettingsSetKeysCommand; import com.streamx.cli.commands.completion.CompleteTemplateIdsCommand; +import com.streamx.cli.commands.completion.CompleteTokenIdsCommand; import com.streamx.cli.commands.completion.CompletionCommand; import com.streamx.cli.commands.context.ContextCommand; import com.streamx.cli.commands.info.InfoCommand; @@ -32,7 +33,8 @@ CompleteNonDefaultTemplateIdsCommand.class, CompleteSettingsKeysCommand.class, CompleteSettingsSetKeysCommand.class, - CompleteContextNamesCommand.class + CompleteContextNamesCommand.class, + CompleteTokenIdsCommand.class } ) public class StreamxCommand extends AbstractCommandGroup { diff --git a/src/main/java/com/streamx/cli/commands/auth/AuthCommand.java b/src/main/java/com/streamx/cli/commands/auth/AuthCommand.java index e4d2cb8b..5c4fb51a 100644 --- a/src/main/java/com/streamx/cli/commands/auth/AuthCommand.java +++ b/src/main/java/com/streamx/cli/commands/auth/AuthCommand.java @@ -2,6 +2,7 @@ import com.streamx.cli.commands.auth.login.LoginCommand; import com.streamx.cli.commands.auth.logout.LogoutCommand; +import com.streamx.cli.commands.auth.token.TokenCommand; import com.streamx.cli.commands.auth.whoami.WhoamiCommand; import com.streamx.cli.framework.AbstractCommandGroup; import picocli.CommandLine; @@ -12,7 +13,8 @@ subcommands = { LoginCommand.class, LogoutCommand.class, - WhoamiCommand.class + WhoamiCommand.class, + TokenCommand.class } ) public class AuthCommand extends AbstractCommandGroup { diff --git a/src/main/java/com/streamx/cli/commands/auth/token/TokenCommand.java b/src/main/java/com/streamx/cli/commands/auth/token/TokenCommand.java new file mode 100644 index 00000000..34d3d757 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/auth/token/TokenCommand.java @@ -0,0 +1,26 @@ +package com.streamx.cli.commands.auth.token; + +import com.streamx.cli.commands.auth.token.create.CreateCommand; +import com.streamx.cli.commands.auth.token.list.ListCommand; +import com.streamx.cli.commands.auth.token.revoke.RevokeCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "token", + header = "Manage personal access tokens", + description = { + "Personal access tokens authenticate the CLI in CI and other non-interactive environments.", + "Set STREAMX_PLATFORM_TOKEN= to use one for platform calls, no login needed.", + "A token acts as you, with your permissions, and does not expire until revoked.", + "These subcommands need a login session: a token cannot manage tokens, so unset " + + "STREAMX_PLATFORM_TOKEN to use them." + }, + subcommands = { + CreateCommand.class, + ListCommand.class, + RevokeCommand.class + } +) +public class TokenCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/auth/token/create/CreateCommand.java b/src/main/java/com/streamx/cli/commands/auth/token/create/CreateCommand.java new file mode 100644 index 00000000..ca303a78 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/auth/token/create/CreateCommand.java @@ -0,0 +1,43 @@ +package com.streamx.cli.commands.auth.token.create; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.AccessTokens; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.ProfileTokensApi; +import com.streamx.cli.platform.generated.model.PersonalAccessTokenResponse; +import picocli.CommandLine; + +@CommandLine.Command( + name = "create", + header = "Create a personal access token", + description = { + "Prints the token once to standard output - copy it now, it cannot be retrieved again." + } +) +public class CreateCommand extends AbstractCommand { + + @CommandLine.Parameters( + index = "0", + paramLabel = "", + description = "A label to recognize the token later (e.g. ci-github-actions)" + ) + public String name; + + @Override + public String getTextOutput(CommandResult result) { + return result.getData().getToken(); + } + + @Override + public CommandResult runCommand() { + AccessTokens.requireInteractiveSession(); + try (PlatformClients client = PlatformClients.fromConfig()) { + PersonalAccessTokenResponse token = new ProfileTokensApi(client).create(name); + System.err.println(msg.authTokenCreated(token.getName())); + return new CommandResult<>(token); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/auth/token/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/auth/token/list/ListCommand.java new file mode 100644 index 00000000..804821a9 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/auth/token/list/ListCommand.java @@ -0,0 +1,66 @@ +package com.streamx.cli.commands.auth.token.list; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; +import com.streamx.cli.platform.AccessTokens; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.ProfileTokensApi; +import com.streamx.cli.platform.generated.model.PersonalAccessTokenSummary; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list", + header = "List your personal access tokens" +) +public class ListCommand extends AbstractCommand> { + + @CommandLine.Option( + names = {"-q", "--quiet"}, + description = "Only display token ids, one per line" + ) + public boolean quiet; + + @Override + public String getTextOutput(CommandResult> result) { + List tokens = result.getData(); + + if (quiet) { + return tokens.stream() + .map(PersonalAccessTokenSummary::getId) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + } + if (tokens.isEmpty()) { + return msg.authTokenListEmpty(); + } + return TextTable.render( + List.of("ID", "NAME", "CREATED", "LAST USED"), + tokens.stream() + .map(token -> Arrays.asList( + token.getId(), + token.getName(), + timestamp(token.getCreatedAt(), "-"), + timestamp(token.getLastUsedAt(), "never"))) + .toList()); + } + + private static String timestamp(OffsetDateTime value, String absent) { + return value == null ? absent : value.toString(); + } + + @Override + public CommandResult> runCommand() { + AccessTokens.requireInteractiveSession(); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new ProfileTokensApi(client).list()); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/auth/token/revoke/RevokeCommand.java b/src/main/java/com/streamx/cli/commands/auth/token/revoke/RevokeCommand.java new file mode 100644 index 00000000..29cf90d5 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/auth/token/revoke/RevokeCommand.java @@ -0,0 +1,36 @@ +package com.streamx.cli.commands.auth.token.revoke; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.AccessTokens; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.ProfileTokensApi; +import com.streamx.cli.platform.TokenIdCompletionCandidates; +import picocli.CommandLine; + +@CommandLine.Command( + name = "revoke", + header = "Revoke a personal access token" +) +public class RevokeCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + paramLabel = "", + description = "The token id (from 'streamx auth token list')", + completionCandidates = TokenIdCompletionCandidates.class + ) + public String id; + + @Override + public CommandResult runCommand() { + AccessTokens.requireInteractiveSession(); + try (PlatformClients client = PlatformClients.fromConfig()) { + new ProfileTokensApi(client).revoke(id); + } + System.out.println(msg.authTokenRevoked()); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/completion/CompleteTokenIdsCommand.java b/src/main/java/com/streamx/cli/commands/completion/CompleteTokenIdsCommand.java new file mode 100644 index 00000000..13863753 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/completion/CompleteTokenIdsCommand.java @@ -0,0 +1,35 @@ +package com.streamx.cli.commands.completion; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.ProfileTokensApi; +import com.streamx.cli.platform.generated.model.PersonalAccessTokenSummary; +import java.util.List; +import java.util.Objects; +import picocli.CommandLine; + +@CommandLine.Command( + name = "__complete-token-ids", + hidden = true, + header = "Internal: list personal access token IDs for shell completion, one per line" +) +public class CompleteTokenIdsCommand extends AbstractCommand> { + + @Override + public CommandResult> runCommand() { + try (PlatformClients client = PlatformClients.completion()) { + return new CommandResult<>(new ProfileTokensApi(client).list().stream() + .map(PersonalAccessTokenSummary::getId) + .filter(Objects::nonNull) + .toList()); + } catch (RuntimeException anyFailure) { + return new CommandResult<>(List.of()); + } + } + + @Override + public String getTextOutput(CommandResult> result) { + return String.join("\n", result.getData()); + } +} diff --git a/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java b/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java index 3e1624df..1ed8fc02 100644 --- a/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java +++ b/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java @@ -6,6 +6,7 @@ import com.streamx.cli.commands.settings.eventtemplates.RegisteredTemplateIdCompletionCandidates; import com.streamx.cli.commands.settings.eventtemplates.TemplateIdCompletionCandidates; import com.streamx.cli.config.ContextNameCompletionCandidates; +import com.streamx.cli.platform.TokenIdCompletionCandidates; import java.io.File; import java.nio.file.Path; import java.util.ArrayList; @@ -215,6 +216,9 @@ private static String getCompletionAction( if (completionCandidates instanceof ContextNameCompletionCandidates) { return "($(streamx __complete-context-names 2>/dev/null))"; } + if (completionCandidates instanceof TokenIdCompletionCandidates) { + return "($(streamx __complete-token-ids 2>/dev/null))"; + } // Any remaining candidates are a fixed list (e.g. roles); the dynamic ones are handled above. if (completionCandidates != null) { String values = renderCandidates(completionCandidates); diff --git a/src/main/java/com/streamx/cli/platform/ProfileTokensApi.java b/src/main/java/com/streamx/cli/platform/ProfileTokensApi.java new file mode 100644 index 00000000..be8e2a72 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/ProfileTokensApi.java @@ -0,0 +1,31 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.platform.generated.api.PersonalAccessTokenResourceApi; +import com.streamx.cli.platform.generated.model.CreatePersonalAccessTokenRequest; +import com.streamx.cli.platform.generated.model.PersonalAccessTokenResponse; +import com.streamx.cli.platform.generated.model.PersonalAccessTokenSummary; +import java.util.List; + +public class ProfileTokensApi { + + private final PlatformClients clients; + private final PersonalAccessTokenResourceApi api; + + public ProfileTokensApi(PlatformClients clients) { + this.clients = clients; + this.api = clients.api(PersonalAccessTokenResourceApi.class); + } + + public PersonalAccessTokenResponse create(String name) { + return clients.call(() -> api.create(new CreatePersonalAccessTokenRequest().name(name), + null, null), PersonalAccessTokenResponse.class); + } + + public List list() { + return clients.callList(() -> api.callList(null, null), PersonalAccessTokenSummary.class); + } + + public void revoke(String id) { + clients.call(() -> api.delete(id, null, null)); + } +} diff --git a/src/main/java/com/streamx/cli/platform/TokenIdCompletionCandidates.java b/src/main/java/com/streamx/cli/platform/TokenIdCompletionCandidates.java new file mode 100644 index 00000000..271b4285 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/TokenIdCompletionCandidates.java @@ -0,0 +1,11 @@ +package com.streamx.cli.platform; + +import java.util.Collections; +import java.util.Iterator; + +public class TokenIdCompletionCandidates implements Iterable { + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } +} diff --git a/src/test/java/com/streamx/cli/commands/auth/token/StubTokensServer.java b/src/test/java/com/streamx/cli/commands/auth/token/StubTokensServer.java new file mode 100644 index 00000000..e8a0cca5 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/auth/token/StubTokensServer.java @@ -0,0 +1,118 @@ +package com.streamx.cli.commands.auth.token; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** Stub of the platform context/token endpoints used by the personal access token commands. */ +public class StubTokensServer implements AutoCloseable { + + public static final String TOKEN = "sxp_v1_" + + "0123456789abcdef0123456789abcdef" + "_" + + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGh" + "_" + + "Zz09Yx"; + public static final String TOKEN_ID = "0123456789abcdef0123456789abcdef"; + + private final HttpServer server; + private final List requests = new ArrayList<>(); + private final List authorizationHeaders = new ArrayList<>(); + private final List requestBodies = new ArrayList<>(); + + private volatile boolean empty; + private volatile int forcedStatus; + private volatile String forcedBody = ""; + + public StubTokensServer() throws IOException { + this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/profile", this::route); + server.start(); + } + + public String getUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + public List getRequests() { + return requests; + } + + public List getAuthorizationHeaders() { + return authorizationHeaders; + } + + public List getRequestBodies() { + return requestBodies; + } + + public void returnNoTokens() { + this.empty = true; + } + + public void failWith(int status, String body) { + this.forcedStatus = status; + this.forcedBody = body; + } + + private void route(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + String method = exchange.getRequestMethod(); + requests.add(method + " " + path); + authorizationHeaders.add( + String.valueOf(exchange.getRequestHeaders().getFirst("Authorization"))); + requestBodies.add(new String(readBody(exchange), StandardCharsets.UTF_8)); + + if (forcedStatus != 0) { + respond(exchange, forcedStatus, forcedBody); + return; + } + if (path.endsWith("/tokens") && "POST".equals(method)) { + respond(exchange, 201, """ + {"id":"%s","name":"ci","token":"%s","createdAt":"2026-07-25T10:00:00Z"} + """.formatted(TOKEN_ID, TOKEN)); + return; + } + if (path.endsWith("/tokens") && "GET".equals(method)) { + respond(exchange, 200, empty ? "[]" : """ + [{"id":"%s","name":"ci","createdAt":"2026-07-25T10:00:00Z","lastUsedAt":null}] + """.formatted(TOKEN_ID)); + return; + } + if ("DELETE".equals(method)) { + respond(exchange, 204, ""); + return; + } + // GET /api/v1/profile - identity behind the credential, used by `auth whoami`. + respond(exchange, 200, """ + {"userId":"user-1","email":"ci@streamx.com","firstName":"Ci","lastName":"Bot", + "displayName":"Ci Bot"} + """); + } + + private static byte[] readBody(HttpExchange exchange) throws IOException { + try (InputStream in = exchange.getRequestBody()) { + return in.readAllBytes(); + } + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + if (bytes.length == 0) { + exchange.sendResponseHeaders(status, -1); + } else { + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + } + exchange.close(); + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/src/test/java/com/streamx/cli/commands/auth/token/TokenCommandIT.java b/src/test/java/com/streamx/cli/commands/auth/token/TokenCommandIT.java new file mode 100644 index 00000000..93575aa1 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/auth/token/TokenCommandIT.java @@ -0,0 +1,207 @@ +package com.streamx.cli.commands.auth.token; + +import static com.streamx.cli.i18n.MessageProvider.msg; +import static org.assertj.core.api.Assertions.assertThat; + +import com.streamx.cli.platform.AccessTokens; +import com.streamx.cli.platform.PlatformConfig; +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.time.Instant; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class TokenCommandIT extends CliBaseIT { + + private StubTokensServer platform; + + private Path getCredentialsPath() { + return streamxHome.resolve("contexts/default/config/credentials.json"); + } + + private void writeCredentials() throws IOException { + Path path = getCredentialsPath(); + Files.createDirectories(path.getParent()); + Files.writeString(path, """ + {"access_token":"test-access-token","refresh_token":"test-refresh-token", + "expires_at":%d,"issuer_url":"http://127.0.0.1:1/realms/streamx", + "client_id":"streamx-cli"} + """.formatted(Instant.now().plusSeconds(300).getEpochSecond())); + } + + @BeforeEach + void setUp() throws IOException { + platform = new StubTokensServer(); + + Properties properties = new Properties(); + properties.setProperty(PlatformConfig.STREAMX_PLATFORM_URL, platform.getUrl()); + Path configFile = getConfigPath(); + Files.createDirectories(configFile.getParent()); + try (OutputStream out = Files.newOutputStream(configFile)) { + properties.store(out, null); + } + writeCredentials(); + } + + @AfterEach + void tearDown() throws IOException { + clearEnv(AccessTokens.STREAMX_PLATFORM_TOKEN); + if (platform != null) { + platform.close(); + } + Files.deleteIfExists(getCredentialsPath()); + } + + @Test + void shouldPrintOnlyTheTokenOnStdoutWhenCreating() throws Exception { + ProcessResult result = exec("auth", "token", "create", "ci"); + + result.assertSuccess(); + // The token is the machine output: stdout must be pipeable, the reminder goes to stderr. + assertThat(result.stdout().strip()).isEqualTo(StubTokensServer.TOKEN); + assertThat(result.stderr()).contains(msg.authTokenCreated("ci")); + assertThat(platform.getRequests()).contains("POST /api/v1/profile/tokens"); + assertThat(platform.getRequestBodies()).anyMatch(body -> body.contains("\"name\":\"ci\"")); + } + + @Test + void shouldListTokens() throws Exception { + ProcessResult result = exec("auth", "token", "list"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("ID", "NAME", "CREATED", "LAST USED"); + assertThat(result.stdout()).contains(StubTokensServer.TOKEN_ID, "ci", "never"); + } + + @Test + void shouldReportEmptyTokenList() throws Exception { + platform.returnNoTokens(); + + ProcessResult result = exec("auth", "token", "list"); + + result.assertSuccess(); + assertThat(result.stdout()).contains(msg.authTokenListEmpty()); + } + + @Test + void shouldRevokeToken() throws Exception { + ProcessResult result = exec("auth", "token", "revoke", StubTokensServer.TOKEN_ID); + + result.assertSuccess(); + assertThat(result.stdout()).contains(msg.authTokenRevoked()); + assertThat(platform.getRequests()) + .contains("DELETE /api/v1/profile/tokens/" + StubTokensServer.TOKEN_ID); + } + + @Test + void shouldSendPersonalAccessTokenAsBearerWithoutStoredLogin() throws Exception { + Files.deleteIfExists(getCredentialsPath()); + setEnv(AccessTokens.STREAMX_PLATFORM_TOKEN, StubTokensServer.TOKEN); + + ProcessResult result = exec("auth", "whoami"); + + result.assertSuccess(); + assertThat(platform.getAuthorizationHeaders()).contains("Bearer " + StubTokensServer.TOKEN); + } + + @Test + void shouldPreferPersonalAccessTokenOverStoredSession() throws Exception { + setEnv(AccessTokens.STREAMX_PLATFORM_TOKEN, StubTokensServer.TOKEN); + + exec("auth", "whoami").assertSuccess(); + + assertThat(platform.getAuthorizationHeaders()).contains("Bearer " + StubTokensServer.TOKEN); + assertThat(platform.getAuthorizationHeaders()) + .doesNotContain("Bearer test-access-token"); + } + + @Test + void shouldRefuseTokenManagementWhenAmbientCredentialIsAToken() throws Exception { + setEnv(AccessTokens.STREAMX_PLATFORM_TOKEN, StubTokensServer.TOKEN); + + for (String[] command : new String[][] { + {"auth", "token", "list"}, + {"auth", "token", "create", "ci"}, + {"auth", "token", "revoke", StubTokensServer.TOKEN_ID}}) { + ProcessResult result = exec(command); + + result.assertExitCode(1); + assertThat(result.stderr()) + .contains(msg.authTokenNeedsLoginSession(AccessTokens.STREAMX_PLATFORM_TOKEN)); + } + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void shouldNotPrintTheTokenOutsideItsOwnField() throws Exception { + ProcessResult result = exec("auth", "token", "create", "-o", "json", "ci"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("\"token\""); + assertThat(result.stdout()).contains(StubTokensServer.TOKEN); + assertThat(result.stderr()).doesNotContain(StubTokensServer.TOKEN); + } + + @Test + void shouldReportAnUnknownTokenIdOnRevoke() throws Exception { + platform.failWith(404, ""); + + ProcessResult result = exec("auth", "token", "revoke", "0".repeat(32)); + + result.assertExitCode(1); + assertThat(result.stdout()).doesNotContain(msg.authTokenRevoked()); + } + + @Test + void shouldReportIdentityForWhoamiWithoutStoredLogin() throws Exception { + Files.deleteIfExists(getCredentialsPath()); + setEnv(AccessTokens.STREAMX_PLATFORM_TOKEN, StubTokensServer.TOKEN); + + ProcessResult result = exec("auth", "whoami"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("Ci Bot", "ci@streamx.com", "user-1"); + assertThat(result.stdout()).contains("personal access token"); + } + + @Test + void shouldListTokenIdsOnlyWhenQuiet() throws Exception { + ProcessResult result = exec("auth", "token", "list", "--quiet"); + + result.assertSuccess(); + assertThat(result.stdout().strip()).isEqualTo(StubTokensServer.TOKEN_ID); + assertThat(result.stdout()).doesNotContain("NAME", "CREATED"); + } + + @Test + void shouldNotRetryOrSuggestLoginWhenTokenIsRejected() throws Exception { + Files.deleteIfExists(getCredentialsPath()); + setEnv(AccessTokens.STREAMX_PLATFORM_TOKEN, StubTokensServer.TOKEN); + platform.failWith(401, ""); + + ProcessResult result = exec("auth", "whoami"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.platformTokenUnauthorized()); + // A token cannot be refreshed, so the rejected credential must not be sent twice. + assertThat(platform.getRequests()).hasSize(1); + } + + @Test + void shouldSurfaceTheServerExplanationOnRefusal() throws Exception { + setEnv(AccessTokens.STREAMX_PLATFORM_TOKEN, StubTokensServer.TOKEN); + platform.failWith(403, "{\"errorMessage\":\"Token owner is no longer active\"}"); + + ProcessResult result = exec("auth", "whoami"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("Token owner is no longer active"); + } +} From 110e0d596c4afd34388f44bfd6b7b44155ef22b2 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Wed, 5 Aug 2026 13:35:46 +0300 Subject: [PATCH 2/2] STX-211 Support token expiry in create and list commands --- .../auth/token/create/CreateCommand.java | 32 ++++++++++++++++++- .../commands/auth/token/list/ListCommand.java | 12 +++++-- .../com/streamx/cli/i18n/MessageProvider.java | 4 +++ .../cli/platform/ProfileTokensApi.java | 7 ++-- .../commands/auth/token/StubTokensServer.java | 8 +++-- .../commands/auth/token/TokenCommandIT.java | 25 +++++++++++++-- 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/streamx/cli/commands/auth/token/create/CreateCommand.java b/src/main/java/com/streamx/cli/commands/auth/token/create/CreateCommand.java index ca303a78..1bfc9b99 100644 --- a/src/main/java/com/streamx/cli/commands/auth/token/create/CreateCommand.java +++ b/src/main/java/com/streamx/cli/commands/auth/token/create/CreateCommand.java @@ -3,11 +3,14 @@ import static com.streamx.cli.i18n.MessageProvider.msg; 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.ProfileTokensApi; import com.streamx.cli.platform.generated.model.PersonalAccessTokenResponse; +import io.quarkus.runtime.configuration.DurationConverter; +import java.time.Duration; import picocli.CommandLine; @CommandLine.Command( @@ -26,6 +29,16 @@ public class CreateCommand extends AbstractCommand ) public String name; + @CommandLine.Option( + names = {"-e", "--expires-in"}, + paramLabel = "", + description = { + "How long the token stays valid, e.g. 20m, 2h, 30d.", + "Omit for a token that never expires." + } + ) + public String expiresIn; + @Override public String getTextOutput(CommandResult result) { return result.getData().getToken(); @@ -34,10 +47,27 @@ public String getTextOutput(CommandResult result) { @Override public CommandResult runCommand() { AccessTokens.requireInteractiveSession(); + Duration lifetime = parseLifetime(expiresIn); try (PlatformClients client = PlatformClients.fromConfig()) { - PersonalAccessTokenResponse token = new ProfileTokensApi(client).create(name); + PersonalAccessTokenResponse token = new ProfileTokensApi(client).create(name, lifetime); System.err.println(msg.authTokenCreated(token.getName())); return new CommandResult<>(token); } } + + private static Duration parseLifetime(String value) { + if (value == null || value.isBlank()) { + return null; + } + Duration lifetime; + try { + lifetime = DurationConverter.parseDuration(value.trim()); + } catch (RuntimeException unparseable) { + throw new CliException(msg.authTokenInvalidExpiry(value)); + } + if (lifetime == null || lifetime.isZero() || lifetime.isNegative()) { + throw new CliException(msg.authTokenInvalidExpiry(value)); + } + return lifetime; + } } diff --git a/src/main/java/com/streamx/cli/commands/auth/token/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/auth/token/list/ListCommand.java index 804821a9..b4673a9b 100644 --- a/src/main/java/com/streamx/cli/commands/auth/token/list/ListCommand.java +++ b/src/main/java/com/streamx/cli/commands/auth/token/list/ListCommand.java @@ -42,13 +42,14 @@ public String getTextOutput(CommandResult> resu return msg.authTokenListEmpty(); } return TextTable.render( - List.of("ID", "NAME", "CREATED", "LAST USED"), + List.of("ID", "NAME", "CREATED", "LAST USED", "EXPIRES"), tokens.stream() .map(token -> Arrays.asList( token.getId(), token.getName(), timestamp(token.getCreatedAt(), "-"), - timestamp(token.getLastUsedAt(), "never"))) + timestamp(token.getLastUsedAt(), "never"), + expiry(token))) .toList()); } @@ -56,6 +57,13 @@ private static String timestamp(OffsetDateTime value, String absent) { return value == null ? absent : value.toString(); } + private static String expiry(PersonalAccessTokenSummary token) { + if (Boolean.TRUE.equals(token.getExpired())) { + return "expired"; + } + return timestamp(token.getExpiresAt(), "never"); + } + @Override public CommandResult> runCommand() { AccessTokens.requireInteractiveSession(); diff --git a/src/main/java/com/streamx/cli/i18n/MessageProvider.java b/src/main/java/com/streamx/cli/i18n/MessageProvider.java index f8368319..856641fc 100644 --- a/src/main/java/com/streamx/cli/i18n/MessageProvider.java +++ b/src/main/java/com/streamx/cli/i18n/MessageProvider.java @@ -34,6 +34,10 @@ public interface MessageProvider { + "Unset %s and run 'streamx auth login' first.") String authTokenNeedsLoginSession(String variableName); + @Message(id = 435, + value = "Invalid expiry '%s'. Use a positive duration such as 20m, 2h or 30d.") + String authTokenInvalidExpiry(String value); + @Message(id = 101, value = "Try '%s%s' for more information on the available options%n") String tryForMoreInformationOnAvailableOptions( String qualifiedCommandName, diff --git a/src/main/java/com/streamx/cli/platform/ProfileTokensApi.java b/src/main/java/com/streamx/cli/platform/ProfileTokensApi.java index be8e2a72..efd0d46b 100644 --- a/src/main/java/com/streamx/cli/platform/ProfileTokensApi.java +++ b/src/main/java/com/streamx/cli/platform/ProfileTokensApi.java @@ -4,6 +4,7 @@ import com.streamx.cli.platform.generated.model.CreatePersonalAccessTokenRequest; import com.streamx.cli.platform.generated.model.PersonalAccessTokenResponse; import com.streamx.cli.platform.generated.model.PersonalAccessTokenSummary; +import java.time.Duration; import java.util.List; public class ProfileTokensApi { @@ -16,8 +17,10 @@ public ProfileTokensApi(PlatformClients clients) { this.api = clients.api(PersonalAccessTokenResourceApi.class); } - public PersonalAccessTokenResponse create(String name) { - return clients.call(() -> api.create(new CreatePersonalAccessTokenRequest().name(name), + public PersonalAccessTokenResponse create(String name, Duration expiresIn) { + String lifetime = expiresIn == null ? null : expiresIn.toString(); + return clients.call(() -> api.create( + new CreatePersonalAccessTokenRequest().name(name).expiresIn(lifetime), null, null), PersonalAccessTokenResponse.class); } diff --git a/src/test/java/com/streamx/cli/commands/auth/token/StubTokensServer.java b/src/test/java/com/streamx/cli/commands/auth/token/StubTokensServer.java index e8a0cca5..6b7ef668 100644 --- a/src/test/java/com/streamx/cli/commands/auth/token/StubTokensServer.java +++ b/src/test/java/com/streamx/cli/commands/auth/token/StubTokensServer.java @@ -17,6 +17,7 @@ public class StubTokensServer implements AutoCloseable { + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGh" + "_" + "Zz09Yx"; public static final String TOKEN_ID = "0123456789abcdef0123456789abcdef"; + public static final String EXPIRED_TOKEN_ID = "e".repeat(32); private final HttpServer server; private final List requests = new ArrayList<>(); @@ -78,8 +79,11 @@ private void route(HttpExchange exchange) throws IOException { } if (path.endsWith("/tokens") && "GET".equals(method)) { respond(exchange, 200, empty ? "[]" : """ - [{"id":"%s","name":"ci","createdAt":"2026-07-25T10:00:00Z","lastUsedAt":null}] - """.formatted(TOKEN_ID)); + [{"id":"%s","name":"ci","createdAt":"2026-07-25T10:00:00Z","lastUsedAt":null, + "expiresAt":null,"expired":false}, + {"id":"%s","name":"old-ci","createdAt":"2026-06-01T10:00:00Z","lastUsedAt":null, + "expiresAt":"2026-07-01T00:00:00Z","expired":true}] + """.formatted(TOKEN_ID, EXPIRED_TOKEN_ID)); return; } if ("DELETE".equals(method)) { diff --git a/src/test/java/com/streamx/cli/commands/auth/token/TokenCommandIT.java b/src/test/java/com/streamx/cli/commands/auth/token/TokenCommandIT.java index 93575aa1..6d832c84 100644 --- a/src/test/java/com/streamx/cli/commands/auth/token/TokenCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/auth/token/TokenCommandIT.java @@ -76,8 +76,28 @@ void shouldListTokens() throws Exception { ProcessResult result = exec("auth", "token", "list"); result.assertSuccess(); - assertThat(result.stdout()).contains("ID", "NAME", "CREATED", "LAST USED"); + assertThat(result.stdout()).contains("ID", "NAME", "CREATED", "LAST USED", "EXPIRES"); assertThat(result.stdout()).contains(StubTokensServer.TOKEN_ID, "ci", "never"); + assertThat(result.stdout()).contains(StubTokensServer.EXPIRED_TOKEN_ID, "expired"); + } + + @Test + void shouldSendTheRequestedExpiry() throws Exception { + ProcessResult result = exec("auth", "token", "create", "ci", "--expires-in", "30d"); + + result.assertSuccess(); + // 30d parses to a Duration whose ISO-8601 form is PT720H - the wire format of expiresIn. + assertThat(platform.getRequestBodies()) + .anyMatch(body -> body.contains("\"expiresIn\":\"PT720H\"")); + } + + @Test + void shouldRejectAnInvalidExpiryBeforeCallingThePlatform() throws Exception { + ProcessResult result = exec("auth", "token", "create", "ci", "--expires-in", "soon"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.authTokenInvalidExpiry("soon")); + assertThat(platform.getRequests()).doesNotContain("POST /api/v1/profile/tokens"); } @Test @@ -176,7 +196,8 @@ void shouldListTokenIdsOnlyWhenQuiet() throws Exception { ProcessResult result = exec("auth", "token", "list", "--quiet"); result.assertSuccess(); - assertThat(result.stdout().strip()).isEqualTo(StubTokensServer.TOKEN_ID); + assertThat(result.stdout().lines().filter(line -> !line.isBlank())) + .containsExactly(StubTokensServer.TOKEN_ID, StubTokensServer.EXPIRED_TOKEN_ID); assertThat(result.stdout()).doesNotContain("NAME", "CREATED"); }