Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ nb-configuration.xml

streamx.log
streamx.*
streamx-*.log
streamx-*.log
# Spec unpacked from the aggregated-openapi artifact at build time (not committed).
src/main/openapi/
41 changes: 41 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<checkstyle-plugin.version>3.6.0</checkstyle-plugin.version>
<graalvm.version>25.0.2</graalvm.version>
<streamx.version>2.0.37</streamx.version>
<aggregated-openapi.version>2.1.0-SNAPSHOT</aggregated-openapi.version>
<cloudevents.version>4.0.1</cloudevents.version>

<test.output.toFile>false</test.output.toFile>
Expand Down Expand Up @@ -52,6 +53,17 @@
<artifactId>quarkus-picocli</artifactId>
</dependency>

<dependency>
<groupId>io.quarkiverse.openapi.generator</groupId>
<artifactId>quarkus-openapi-generator</artifactId>
<version>2.11.0</version>
</dependency>

<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>

<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
Expand Down Expand Up @@ -311,6 +323,35 @@
</resources>

<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>unpack-platform-openapi-spec</id>
<phase>initialize</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>com.streamx.platform</groupId>
<artifactId>streamx-platform-aggregated-openapi</artifactId>
<version>${aggregated-openapi.version}</version>
<type>jar</type>
<includes>META-INF/openapi/openapi.yaml</includes>
<outputDirectory>${project.basedir}/src/main/openapi</outputDirectory>
<fileMappers>
<org.codehaus.plexus.components.io.filemappers.FlattenFileMapper />
</fileMappers>
</artifactItem>
</artifactItems>
<overWriteSnapshots>true</overWriteSnapshots>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/com/streamx/cli/auth/AccessTokenClaims.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
52 changes: 52 additions & 0 deletions src/main/java/com/streamx/cli/auth/AuthConfig.java
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/streamx/cli/auth/Credentials.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
119 changes: 119 additions & 0 deletions src/main/java/com/streamx/cli/auth/CredentialsStore.java
Original file line number Diff line number Diff line change
@@ -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<Credentials> 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);
}
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/streamx/cli/auth/Identity.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
58 changes: 58 additions & 0 deletions src/main/java/com/streamx/cli/auth/InteractiveGrant.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading