initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user