initial commit

This commit is contained in:
2026-05-09 12:47:01 +02:00
parent dec32b5c85
commit a0de594273
14 changed files with 1029 additions and 0 deletions
@@ -0,0 +1,13 @@
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@@ -0,0 +1,33 @@
package com.example.demo.zitadel;
import io.grpc.CallCredentials;
import io.grpc.Metadata;
import io.grpc.Status;
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
class BearerTokenInterceptor extends CallCredentials {
private static final Metadata.Key<String> AUTHORIZATION =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private final Callable<String> tokenSource;
BearerTokenInterceptor(Callable<String> tokenSource) {
this.tokenSource = tokenSource;
}
@Override
public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, MetadataApplier applier) {
appExecutor.execute(() -> {
try {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, "Bearer " + tokenSource.call());
applier.apply(headers);
} catch (Exception e) {
applier.fail(Status.UNAUTHENTICATED.withDescription("token fetch failed").withCause(e));
}
});
}
}
@@ -0,0 +1,39 @@
package com.example.demo.zitadel;
import io.grpc.StatusRuntimeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import zitadel.user.v2.UserServiceGrpc.UserServiceBlockingStub;
import zitadel.user.v2.UserServiceOuterClass.ListUsersRequest;
import zitadel.user.v2.UserServiceOuterClass.ListUsersResponse;
import zitadel.user.v2.UserOuterClass.User;
@Component
class ListUsersRunner implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(ListUsersRunner.class);
private final UserServiceBlockingStub userService;
ListUsersRunner(UserServiceBlockingStub userService) {
this.userService = userService;
}
@Override
public void run(String... args) {
try {
ListUsersResponse response = userService.listUsers(ListUsersRequest.newBuilder().build());
log.info("Zitadel returned {} users (total {}):",
response.getResultCount(), response.getDetails().getTotalResult());
for (User user : response.getResultList()) {
log.info(" - {} ({})", user.getUserId(), user.getPreferredLoginName());
}
} catch (StatusRuntimeException e) {
log.error("ListUsers failed: status={} description={}",
e.getStatus().getCode(), e.getStatus().getDescription());
}
}
}
@@ -0,0 +1,47 @@
package com.example.demo.zitadel;
import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import jakarta.annotation.PreDestroy;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import zitadel.user.v2.UserServiceGrpc;
import zitadel.user.v2.UserServiceGrpc.UserServiceBlockingStub;
import java.util.concurrent.TimeUnit;
@Configuration
@EnableConfigurationProperties(ZitadelProperties.class)
public class ZitadelGrpcConfig {
private ManagedChannel channel;
@Bean
ManagedChannel zitadelChannel(ZitadelProperties props) {
// Strip the port from :authority — Zitadel's instance lookup matches on hostname,
// and forAddress() sets :authority to "host:port" which doesn't match ExternalDomain.
NettyChannelBuilder builder = NettyChannelBuilder.forAddress(props.getHost(), props.getPort())
.overrideAuthority(props.getHost());
if (props.isPlaintext()) {
builder.usePlaintext();
} else {
builder.useTransportSecurity();
}
this.channel = builder.build();
return this.channel;
}
@Bean
UserServiceBlockingStub userServiceStub(ManagedChannel channel, ZitadelTokenSource tokens) {
return UserServiceGrpc.newBlockingStub(channel)
.withCallCredentials(new BearerTokenInterceptor(tokens::currentToken));
}
@PreDestroy
void shutdown() throws InterruptedException {
if (channel != null) {
channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
}
}
}
@@ -0,0 +1,32 @@
package com.example.demo.zitadel;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "zitadel")
public class ZitadelProperties {
private String host;
private int port = 443;
private boolean plaintext = false;
/** Path to the Zitadel-generated machine-user JSON key (type/keyId/userId/key). */
private String keyPath;
/** OIDC audience / issuer. Defaults to https://{host}. */
private String audience;
public String getHost() { return host; }
public void setHost(String host) { this.host = host; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public boolean isPlaintext() { return plaintext; }
public void setPlaintext(boolean plaintext) { this.plaintext = plaintext; }
public String getKeyPath() { return keyPath; }
public void setKeyPath(String keyPath) { this.keyPath = keyPath; }
public String getAudience() {
return audience != null ? audience : "https://" + host;
}
public void setAudience(String audience) { this.audience = audience; }
}
@@ -0,0 +1,132 @@
package com.example.demo.zitadel;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
@Component
class ZitadelTokenSource {
/** Required to make the token usable against the Zitadel API. */
private static final String ZITADEL_API_SCOPE = "openid urn:zitadel:iam:org:project:id:zitadel:aud";
private static final String JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
private final ZitadelProperties props;
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
private final String userId;
private final String keyId;
private final RSAPrivateKey privateKey;
private final URI tokenEndpoint;
private volatile String cachedToken;
private volatile Instant cachedExpiry = Instant.EPOCH;
ZitadelTokenSource(ZitadelProperties props) throws Exception {
this.props = props;
JsonNode key = mapper.readTree(Files.readAllBytes(Path.of(props.getKeyPath())));
this.userId = key.get("userId").asText();
this.keyId = key.get("keyId").asText();
this.privateKey = parsePrivateKey(key.get("key").asText());
this.tokenEndpoint = URI.create(props.getAudience() + "/oauth/v2/token");
}
synchronized String currentToken() throws Exception {
if (cachedToken != null && Instant.now().isBefore(cachedExpiry.minusSeconds(30))) {
return cachedToken;
}
String assertion = buildAssertion();
String body = "grant_type=" + URLEncoder.encode(JWT_BEARER_GRANT, StandardCharsets.UTF_8)
+ "&assertion=" + URLEncoder.encode(assertion, StandardCharsets.UTF_8)
+ "&scope=" + URLEncoder.encode(ZITADEL_API_SCOPE, StandardCharsets.UTF_8);
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder(tokenEndpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new IllegalStateException("Zitadel token endpoint returned " + resp.statusCode() + ": " + resp.body());
}
JsonNode json = mapper.readTree(resp.body());
this.cachedToken = json.get("access_token").asText();
this.cachedExpiry = Instant.now().plusSeconds(json.get("expires_in").asLong());
return cachedToken;
}
private String buildAssertion() {
Instant now = Instant.now();
return JWT.create()
.withKeyId(keyId)
.withIssuer(userId)
.withSubject(userId)
.withAudience(props.getAudience())
.withIssuedAt(Date.from(now))
.withExpiresAt(Date.from(now.plusSeconds(60 * 60)))
.sign(Algorithm.RSA256(null, privateKey));
}
private static RSAPrivateKey parsePrivateKey(String pem) throws Exception {
boolean pkcs1 = pem.contains("BEGIN RSA PRIVATE KEY");
String stripped = pem
.replaceAll("-----BEGIN [A-Z ]+-----", "")
.replaceAll("-----END [A-Z ]+-----", "")
.replaceAll("\\s+", "");
byte[] der = Base64.getDecoder().decode(stripped);
if (pkcs1) {
der = wrapPkcs1AsPkcs8(der);
}
return (RSAPrivateKey) KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(der));
}
/** PKCS#8 = SEQUENCE { version=0, AlgorithmIdentifier rsaEncryption, OCTET STRING { pkcs1 } }. */
private static byte[] wrapPkcs1AsPkcs8(byte[] pkcs1) {
byte[] version = { 0x02, 0x01, 0x00 };
byte[] algId = {
0x30, 0x0D,
0x06, 0x09, 0x2A, (byte) 0x86, 0x48, (byte) 0x86, (byte) 0xF7, 0x0D, 0x01, 0x01, 0x01,
0x05, 0x00
};
byte[] octet = concat(new byte[]{0x04}, encodeLen(pkcs1.length), pkcs1);
byte[] inner = concat(version, algId, octet);
return concat(new byte[]{0x30}, encodeLen(inner.length), inner);
}
private static byte[] encodeLen(int len) {
if (len < 0x80) return new byte[]{ (byte) len };
if (len < 0x100) return new byte[]{ (byte) 0x81, (byte) len };
if (len < 0x10000) return new byte[]{ (byte) 0x82, (byte) (len >> 8), (byte) len };
return new byte[]{ (byte) 0x83, (byte) (len >> 16), (byte) (len >> 8), (byte) len };
}
private static byte[] concat(byte[]... parts) {
int total = 0;
for (byte[] p : parts) total += p.length;
byte[] out = new byte[total];
int pos = 0;
for (byte[] p : parts) {
System.arraycopy(p, 0, out, pos, p.length);
pos += p.length;
}
return out;
}
}
+14
View File
@@ -0,0 +1,14 @@
spring.application.name=demo
# Zitadel Cloud instance host (no scheme, no port)
zitadel.host=${ZITADEL_HOST:auth.octoflow.unom.io}
# 443 + TLS for Zitadel Cloud
zitadel.port=443
zitadel.plaintext=false
# Path to the Zitadel-generated machine-user JSON key
zitadel.key-path=${ZITADEL_KEY_PATH:./service_account.json}
# Optional: OIDC audience override. Defaults to https://${zitadel.host}
# zitadel.audience=https://auth.octoflow.unom.io
# Don't start the embedded web server — this is a client-only app
spring.main.web-application-type=none
@@ -0,0 +1,13 @@
package com.example.demo;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class DemoApplicationTests {
@Test
void contextLoads() {
}
}