first commit

This commit is contained in:
Radek Davidek 2026-06-30 17:38:52 +02:00
commit 4df08a68e0
15 changed files with 1314 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# adfs-auth-ms
Jednoduchy REST wrapper nad puvodni logikou `AdfsAuthMediator`.
## Co dela
- nacte konfiguraci z YAML
- vytvori `client_assertion` stejne jako puvodni Java callout
- zavola ADFS token endpoint
- drzi access token v pametove cache, dokud nevyprsi
- vystavi endpoint `GET /token` a `GET /health`
## Konfigurace
Konfigurace je v `config/config.yaml` nebo ve classpath `config.yaml`.
Klice v sekci `adfs` zachovavaji stejne nazvy jako puvodni policy:
- `tokenUrl`
- `audience`
- `resource`
- `clientId`
- `certificate`
- `privateKey`
- `proxyHost`
- `proxyPort`
- `proxyUser`
- `proxyPassword`
## Spusteni
```bash
mvn package
java -jar target/adfs-auth-ms-1.0-SNAPSHOT.jar
```
## API
```bash
curl http://localhost:8080/health
curl http://localhost:8080/token
```

20
adfs.crt Normal file
View File

@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDLTCCAhWgAwIBAgIQMa6VTqqCHbVHuWPdFQ4JtzANBgkqhkiG9w0BAQsFADAb
MRkwFwYDVQQDDBBmcy5rb21lcmNwb2oubG9jMB4XDTI2MDYzMDEzNDcwMVoXDTI3
MDYzMDE0MDcwMVowGzEZMBcGA1UEAwwQZnMua29tZXJjcG9qLmxvYzCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBANVbpYlq8O2QLxWcZHjBnBCoj69Aq9fH
NS7psQuFeYvc2legnbgcz0sIE/RSUzRw9t09RaHApig0TOkhYtezuETDEQdGqoay
W8sRaCKMFms5S3dL4MFBhVyerAiIXLOh5qfreshXh+XdRfHv0AYOx83JrXy4z9xh
AALQ23udKdRPbd/acYN/TAgiioclQsTZo8Qn7DzZvwjItzX5pvWqkSDeBJCNIDJ9
+d7w4SJuSrm4+dFbiHd7NT/qjfQikAw0kLiDBHXqBdWpBBzbssZW3iPxep5u9kQn
mnOEeGbI1egkCXBzm1Fxr8MBMg4j2NL/zG80FzHoyWBsfG2XGNQOe8ECAwEAAaNt
MGswDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATAbBgNVHREEFDASghBmcy5rb21lcmNwb2oubG9jMB0GA1UdDgQWBBR1NFW+QJLH
kFdSHbmoLOX2PoeAOTANBgkqhkiG9w0BAQsFAAOCAQEAUf487OGTFkNZCcdBdNsQ
YsNqxFz69y01oELQ8hX38So46MnnDewUiWu7taPOCdsdPNMOfsUzFEYMvEGJJd5i
GaUHNAqP4I0RXEeSLH3H5T2OYkBvduDxbUBCCtIbX0BbveNiNcti/vklgimJN1JK
LRuEIMZ0iucuDAzCkgD7ZoLPk9qGP2Jc0AKmuFLRRmxNFTePTWBOOBTThUo/RieM
k1yDnPMjzWgTLSZbtJgfjFJ9XuTmIwNukdDFFDzNV0OfyFcOnV9x703rTT5S/oWb
ceFCFFzA6tXF4ezdGgMfqyjuXOR24jy0xQ8rdrP1zWeH+Une2yzuG0ILvq/RtXDW
zQ==
-----END CERTIFICATE-----

83
pom.xml Normal file
View File

@ -0,0 +1,83 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cz.trask</groupId>
<artifactId>adfs-auth-ms</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<jackson.version>2.18.2</jackson.version>
<log4j.version>2.24.3</log4j.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>${log4j.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${log4j.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>${maven.compiler.target}</release>
</configuration>
</plugin>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.7.1</version>
<configuration>
<archive>
<manifest>
<mainClass>cz.trask.adfsauthms.AdfsAuthMsServer</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<appendAssemblyId>false</appendAssemblyId>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
</resources>
</build>
</project>

View File

@ -0,0 +1,117 @@
package cz.trask.adfsauthms;
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.security.KeyStore;
import java.util.List;
import java.util.concurrent.Executors;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpsConfigurator;
import com.sun.net.httpserver.HttpsServer;
import cz.trask.adfsauthms.config.AppConfig;
import cz.trask.adfsauthms.config.AppConfig.ContextConfig;
import cz.trask.adfsauthms.config.ConfigurationManager;
import cz.trask.adfsauthms.context.BaseContextHandler;
import cz.trask.adfsauthms.service.AdfsTokenService;
public class AdfsAuthMsServer {
private static final Logger logger = LogManager.getLogger(AdfsAuthMsServer.class);
private final HttpServer server;
public AdfsAuthMsServer() throws Exception {
logger.debug("Initializing AdfsAuthMsServer");
ConfigurationManager configurationManager = ConfigurationManager.load();
AppConfig config = configurationManager.getConfig();
ObjectMapper objectMapper = new ObjectMapper();
AdfsTokenService tokenService = new AdfsTokenService(config.getAdfs(), objectMapper);
this.server = createServer(config);
List<ContextConfig> contexts = config.getServer().getContexts().getContext();
for (ContextConfig contextConfig : contexts) {
logger.debug("Creating context: {} -> {}", contextConfig.getPath(), contextConfig.getClassName());
BaseContextHandler handler = instantiateHandler(contextConfig.getClassName());
handler.init(config, tokenService, objectMapper);
server.createContext(contextConfig.getPath(), handler);
}
server.setExecutor(Executors.newFixedThreadPool(config.getServer().getThreads()));
}
public static void main(String[] args) throws Exception {
try {
AdfsAuthMsServer server = new AdfsAuthMsServer();
server.start();
} catch (Exception e) {
logger.error("Failed to start ADFS auth microservice", e);
System.exit(1);
}
}
public void start() {
server.start();
logger.info("ADFS auth microservice started on {}", server.getAddress());
}
private HttpServer createServer(AppConfig config) throws Exception {
String type = config.getServer().getType();
int port = config.getServer().getPort();
if ("https".equalsIgnoreCase(type)) {
HttpsServer httpsServer = HttpsServer.create(new InetSocketAddress(port), 0);
httpsServer.setHttpsConfigurator(new HttpsConfigurator(buildSslContext(config)));
return httpsServer;
}
return HttpServer.create(new InetSocketAddress(port), 0);
}
private SSLContext buildSslContext(AppConfig config) throws Exception {
AppConfig.TlsConfig tlsConfig = config.getServer().getTls();
if (tlsConfig == null || tlsConfig.getPath() == null || tlsConfig.getPassphrase() == null) {
throw new IllegalArgumentException("TLS configuration is required for https server.type");
}
KeyStore keyStore = KeyStore.getInstance("JKS");
try (FileInputStream inputStream = new FileInputStream(tlsConfig.getPath())) {
keyStore.load(inputStream, tlsConfig.getPassphrase().toCharArray());
}
char[] privateKeyPassphrase = tlsConfig.getPrivatekey() != null
&& tlsConfig.getPrivatekey().getPassphrase() != null
? tlsConfig.getPrivatekey().getPassphrase().toCharArray()
: tlsConfig.getPassphrase().toCharArray();
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("SunX509");
keyManagerFactory.init(keyStore, privateKeyPassphrase);
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("SunX509");
trustManagerFactory.init(keyStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
return sslContext;
}
private BaseContextHandler instantiateHandler(String className) throws Exception {
if (className == null || className.isBlank()) {
throw new IllegalArgumentException("Context handler class is missing.");
}
Class<?> handlerClass = Class.forName(className);
Object instance = handlerClass.getDeclaredConstructor().newInstance();
if (!(instance instanceof BaseContextHandler)) {
throw new IllegalArgumentException("Class " + className + " is not a BaseContextHandler");
}
return (BaseContextHandler) instance;
}
}

View File

@ -0,0 +1,97 @@
package cz.trask.adfsauthms.config;
import java.util.List;
public class AdfsConfig {
private String tokenUrl;
private String audience;
private String resource;
private List<String> clientIds;
private String certificate;
private String privateKey;
private String proxyHost;
private String proxyPort;
private String proxyUser;
private String proxyPassword;
public String getTokenUrl() {
return tokenUrl;
}
public void setTokenUrl(String tokenUrl) {
this.tokenUrl = tokenUrl;
}
public String getAudience() {
return audience;
}
public void setAudience(String audience) {
this.audience = audience;
}
public String getResource() {
return resource;
}
public void setResource(String resource) {
this.resource = resource;
}
public List<String> getClientIds() {
return clientIds;
}
public void setClientIds(List<String> clientIds) {
this.clientIds = clientIds;
}
public String getCertificate() {
return certificate;
}
public void setCertificate(String certificate) {
this.certificate = certificate;
}
public String getPrivateKey() {
return privateKey;
}
public void setPrivateKey(String privateKey) {
this.privateKey = privateKey;
}
public String getProxyHost() {
return proxyHost;
}
public void setProxyHost(String proxyHost) {
this.proxyHost = proxyHost;
}
public String getProxyPort() {
return proxyPort;
}
public void setProxyPort(String proxyPort) {
this.proxyPort = proxyPort;
}
public String getProxyUser() {
return proxyUser;
}
public void setProxyUser(String proxyUser) {
this.proxyUser = proxyUser;
}
public String getProxyPassword() {
return proxyPassword;
}
public void setProxyPassword(String proxyPassword) {
this.proxyPassword = proxyPassword;
}
}

View File

@ -0,0 +1,159 @@
package cz.trask.adfsauthms.config;
import java.util.ArrayList;
import java.util.List;
public class AppConfig {
private ServerConfig server = new ServerConfig();
private AdfsConfig adfs = new AdfsConfig();
private String backendUrl;
public ServerConfig getServer() {
return server;
}
public void setServer(ServerConfig server) {
this.server = server;
}
public AdfsConfig getAdfs() {
return adfs;
}
public void setAdfs(AdfsConfig adfs) {
this.adfs = adfs;
}
public String getBackendUrl() {
return backendUrl;
}
public void setBackendUrl(String backendUrl) {
this.backendUrl = backendUrl;
}
public static class ServerConfig {
private String type = "http";
private int port = 8080;
private int threads = 8;
private TlsConfig tls = new TlsConfig();
private ContextsConfig contexts = new ContextsConfig();
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public int getThreads() {
return threads;
}
public void setThreads(int threads) {
this.threads = threads;
}
public TlsConfig getTls() {
return tls;
}
public void setTls(TlsConfig tls) {
this.tls = tls;
}
public ContextsConfig getContexts() {
return contexts;
}
public void setContexts(ContextsConfig contexts) {
this.contexts = contexts;
}
}
public static class TlsConfig {
private String path;
private String passphrase;
private PrivateKeyConfig privatekey = new PrivateKeyConfig();
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getPassphrase() {
return passphrase;
}
public void setPassphrase(String passphrase) {
this.passphrase = passphrase;
}
public PrivateKeyConfig getPrivatekey() {
return privatekey;
}
public void setPrivatekey(PrivateKeyConfig privatekey) {
this.privatekey = privatekey;
}
}
public static class PrivateKeyConfig {
private String passphrase;
public String getPassphrase() {
return passphrase;
}
public void setPassphrase(String passphrase) {
this.passphrase = passphrase;
}
}
public static class ContextsConfig {
private List<ContextConfig> context = new ArrayList<>();
public List<ContextConfig> getContext() {
return context;
}
public void setContext(List<ContextConfig> context) {
this.context = context;
}
}
public static class ContextConfig {
private String path;
private String className;
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getClassName() {
return className;
}
@com.fasterxml.jackson.annotation.JsonProperty("class")
public void setClassName(String className) {
this.className = className;
}
}
}

View File

@ -0,0 +1,58 @@
package cz.trask.adfsauthms.config;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class ConfigurationManager {
private static final Logger logger = LogManager.getLogger(ConfigurationManager.class);
private static final String CONFIG_DIRECTORY = "config";
private static final String CONFIG_FILE_NAME = "config.yaml";
private final ObjectMapper mapper;
private final AppConfig config;
private ConfigurationManager() throws IOException {
this.mapper = new ObjectMapper(new YAMLFactory());
this.mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
try (InputStream inputStream = openConfig()) {
this.config = mapper.readValue(inputStream, AppConfig.class);
}
}
public static ConfigurationManager load() throws IOException {
return new ConfigurationManager();
}
public AppConfig getConfig() {
return config;
}
private InputStream openConfig() throws IOException {
Path configPath = Paths.get(CONFIG_DIRECTORY, CONFIG_FILE_NAME);
if (Files.isReadable(configPath)) {
logger.info("Loading configuration from file: {}", configPath.toAbsolutePath());
return Files.newInputStream(configPath);
}
InputStream classpathStream = ConfigurationManager.class.getClassLoader().getResourceAsStream(CONFIG_FILE_NAME);
if (classpathStream != null) {
logger.info("Loading configuration from classpath: {}", CONFIG_FILE_NAME);
return classpathStream;
}
logger.error("Configuration file '{}' not found in {} or classpath", CONFIG_FILE_NAME, CONFIG_DIRECTORY);
throw new IOException("Cannot find configuration file '" + CONFIG_DIRECTORY + "/" + CONFIG_FILE_NAME + "'.");
}
}

View File

@ -0,0 +1,117 @@
package cz.trask.adfsauthms.context;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import cz.trask.adfsauthms.config.AppConfig;
import cz.trask.adfsauthms.service.AdfsTokenService;
public abstract class BaseContextHandler implements HttpHandler {
protected final Logger logger = LogManager.getLogger(getClass());
protected static final String METHOD_GET = "GET";
protected static final String METHOD_POST = "POST";
protected static final String CONTENT_TYPE_JSON = "application/json;charset=UTF-8";
protected static final String HEADER_CONTENT_TYPE = "Content-Type";
protected static final String HEADER_CACHE_CONTROL = "Cache-Control";
protected static final String HEADER_PRAGMA = "Pragma";
protected static final String HEADER_EXPIRES = "Expires";
protected static final String HEADER_AUTHORIZATION = "Authorization";
protected static final String VAL_NO_CACHE = "no-cache, no-store, must-revalidate";
protected static final String VAL_PRAGMA_NO_CACHE = "no-cache";
protected static final String VAL_EXPIRES_ZERO = "0";
protected AppConfig appConfig;
protected AdfsTokenService tokenService;
protected ObjectMapper objectMapper;
public void init(AppConfig appConfig, AdfsTokenService tokenService, ObjectMapper objectMapper) {
this.appConfig = appConfig;
this.tokenService = tokenService;
this.objectMapper = objectMapper;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
logger.debug("Received request: {} {}", method, path);
try {
processRequest(exchange);
} catch (Exception e) {
logger.error("Error processing request: {} {}", method, path, e);
sendError(exchange, 500, "internal_error", "An unexpected error occurred.");
}
}
protected void processRequest(HttpExchange exchange) throws IOException {
String method = exchange.getRequestMethod();
if (METHOD_GET.equalsIgnoreCase(method)) {
processRequestGet(exchange);
} else if (METHOD_POST.equalsIgnoreCase(method)) {
processRequestPost(exchange);
} else {
sendError(exchange, 405, "method_not_allowed", "Unsupported method: " + method);
}
}
protected void processRequestGet(HttpExchange exchange) throws IOException {
sendError(exchange, 405, "method_not_allowed", "GET not supported.");
}
protected void processRequestPost(HttpExchange exchange) throws IOException {
sendError(exchange, 405, "method_not_allowed", "POST not supported.");
}
protected void sendJson(HttpExchange exchange, int statusCode, Object payload) throws IOException {
byte[] responseBytes = objectMapper.writeValueAsBytes(payload);
sendResponse(exchange, statusCode, CONTENT_TYPE_JSON, responseBytes);
}
protected void sendResponse(HttpExchange exchange, int statusCode, String contentType, byte[] body) throws IOException {
int length = body != null ? body.length : -1;
logger.debug("Sending response: HTTP {} ({} bytes)", statusCode, length);
Headers headers = exchange.getResponseHeaders();
if (contentType != null) {
headers.set(HEADER_CONTENT_TYPE, contentType);
}
headers.set(HEADER_CACHE_CONTROL, VAL_NO_CACHE);
headers.set(HEADER_PRAGMA, VAL_PRAGMA_NO_CACHE);
headers.set(HEADER_EXPIRES, VAL_EXPIRES_ZERO);
exchange.sendResponseHeaders(statusCode, length > 0 ? length : 0);
if (body != null && body.length > 0) {
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(body);
}
}
}
protected void sendError(HttpExchange exchange, int statusCode, String error, String errorDescription)
throws IOException {
logger.debug("Sending error response: HTTP {} - {}: {}", statusCode, error, errorDescription);
Map<String, String> response = new LinkedHashMap<>();
response.put("error", error);
response.put("error_description", errorDescription);
sendJson(exchange, statusCode, response);
}
protected String getRequestBody(InputStream requestBody) throws IOException {
return new String(requestBody.readAllBytes(), StandardCharsets.UTF_8);
}
}

View File

@ -0,0 +1,19 @@
package cz.trask.adfsauthms.context;
import java.io.IOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import com.sun.net.httpserver.HttpExchange;
public class HealthHandler extends BaseContextHandler {
@Override
protected void processRequestGet(HttpExchange exchange) throws IOException {
Map<String, Object> response = new LinkedHashMap<>();
response.put("status", "UP");
response.put("timestamp", Instant.now().toString());
sendJson(exchange, 200, response);
}
}

View File

@ -0,0 +1,203 @@
package cz.trask.adfsauthms.context;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import cz.trask.adfsauthms.dto.TokenPayloadIdp;
public class ProcessHandler extends BaseContextHandler {
private static final String AUTH_PREFIX_BEARER = "Bearer ";
private static final String ERR_SERVER_ERROR = "server_error";
private static final String ERR_TOO_MANY_REQUESTS = "too_many_requests";
private static final String ERR_INVALID_CONFIG = "invalid_configuration";
private static final String ERR_PROCESS_FAILED = "process_failed";
private final AtomicInteger clientIdIndex = new AtomicInteger(0);
@Override
protected void processRequest(HttpExchange exchange) throws IOException {
handleProxy(exchange);
}
private void handleProxy(HttpExchange exchange) throws IOException {
try {
List<String> clientIds = appConfig.getAdfs().getClientIds();
if (clientIds == null || clientIds.isEmpty()) {
logger.error("No client IDs configured in adfs.clientIds");
sendError(exchange, 500, ERR_SERVER_ERROR, "ADFS Client IDs not configured.");
return;
}
int index = clientIdIndex.get() % clientIds.size();
String currentClientId = clientIds.get(index);
TokenPayloadIdp token = tokenService.getToken(currentClientId);
String accessToken = token.getAccessToken();
String backendBaseUrl = appConfig.getBackendUrl();
if (backendBaseUrl == null || backendBaseUrl.isBlank()) {
logger.error("backend_url is not configured");
sendError(exchange, 500, ERR_SERVER_ERROR, "Backend URL not configured.");
return;
}
// Determine target URL
String contextPath = exchange.getHttpContext().getPath();
String fullPath = exchange.getRequestURI().getPath();
String relativePath = fullPath.substring(contextPath.length());
String query = exchange.getRequestURI().getRawQuery();
URI baseUri = new URI(backendBaseUrl);
String targetPath = baseUri.getPath();
if (targetPath == null) targetPath = "";
if (targetPath.endsWith("/") && relativePath.startsWith("/")) {
targetPath += relativePath.substring(1);
} else if (!targetPath.endsWith("/") && !relativePath.startsWith("/") && !relativePath.isEmpty()) {
targetPath += "/" + relativePath;
} else {
targetPath += relativePath;
}
String targetQuery = baseUri.getRawQuery();
if (query != null && !query.isEmpty()) {
if (targetQuery == null || targetQuery.isEmpty()) {
targetQuery = query;
} else {
targetQuery += "&" + query;
}
}
URI targetUri = new URI(baseUri.getScheme(), baseUri.getAuthority(), targetPath, null, null);
String targetUrl = targetUri.toString();
if (targetQuery != null && !targetQuery.isEmpty()) {
targetUrl += "?" + targetQuery;
}
String method = exchange.getRequestMethod();
byte[] body = null;
if (exchange.getRequestBody() != null) {
body = exchange.getRequestBody().readAllBytes();
}
logger.info("Forwarding {} request to backend: {} (Client ID: {})", method, targetUrl, currentClientId);
BackendResponse backendResponse = callBackend(targetUrl, method, exchange.getRequestHeaders(), body, accessToken);
int retryCount = 0;
int maxRetries = clientIds.size() - 1;
while (backendResponse.statusCode == 429 && retryCount < maxRetries) {
retryCount++;
int nextIndex = clientIdIndex.incrementAndGet() % clientIds.size();
String retryClientId = clientIds.get(nextIndex);
logger.warn("Backend returned 429. Retrying ({}/{}) with next client ID: {}...",
retryCount, maxRetries, retryClientId);
token = tokenService.getToken(retryClientId);
accessToken = token.getAccessToken();
logger.info("Retrying request to backend with new token (Client ID: {})", retryClientId);
backendResponse = callBackend(targetUrl, method, exchange.getRequestHeaders(), body, accessToken);
}
if (backendResponse.statusCode == 429) {
logger.error("Throttling limit reached. Exhausted all {} identities and backend still returns 429.", clientIds.size());
sendError(exchange, 429, ERR_TOO_MANY_REQUESTS,
"All configured client identities are currently throttled by the backend after " + retryCount + " retries.");
return;
}
sendResponse(exchange, backendResponse.statusCode, backendResponse.contentType, backendResponse.body);
} catch (IllegalArgumentException e) {
sendError(exchange, 400, ERR_INVALID_CONFIG, e.getMessage());
} catch (Exception e) {
logger.error("Failed to process request and forward to backend", e);
sendError(exchange, 502, ERR_PROCESS_FAILED, e.getMessage());
}
}
private BackendResponse callBackend(String targetUrl, String method, Headers requestHeaders, byte[] requestBody, String token) throws Exception {
HttpURLConnection connection = null;
try {
URL url = new URI(targetUrl).toURL();
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod(method);
// Forward headers
for (Map.Entry<String, List<String>> entry : requestHeaders.entrySet()) {
String key = entry.getKey();
if (shouldForwardHeader(key)) {
for (String value : entry.getValue()) {
connection.addRequestProperty(key, value);
}
}
}
connection.setRequestProperty(HEADER_AUTHORIZATION, AUTH_PREFIX_BEARER + token);
connection.setConnectTimeout(5000);
connection.setReadTimeout(5000);
if (requestBody != null && requestBody.length > 0) {
connection.setDoOutput(true);
try (var os = connection.getOutputStream()) {
os.write(requestBody);
}
}
int responseCode = connection.getResponseCode();
String contentType = connection.getContentType();
logger.debug("Backend response code: {}, Content-Type: {}", responseCode, contentType);
InputStream responseStream = responseCode >= 400 ? connection.getErrorStream() : connection.getInputStream();
byte[] body = null;
if (responseStream != null) {
body = responseStream.readAllBytes();
}
return new BackendResponse(responseCode, contentType, body);
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private boolean shouldForwardHeader(String headerName) {
if (headerName == null) return false;
String lowerCaseHeader = headerName.toLowerCase();
return !lowerCaseHeader.equals("host") &&
!lowerCaseHeader.equals("authorization") &&
!lowerCaseHeader.equals("content-length") &&
!lowerCaseHeader.equals("connection") &&
!lowerCaseHeader.equals("keep-alive") &&
!lowerCaseHeader.equals("proxy-authenticate") &&
!lowerCaseHeader.equals("proxy-authorization") &&
!lowerCaseHeader.equals("te") &&
!lowerCaseHeader.equals("trailers") &&
!lowerCaseHeader.equals("transfer-encoding") &&
!lowerCaseHeader.equals("upgrade");
}
private static class BackendResponse {
final int statusCode;
final String contentType;
final byte[] body;
BackendResponse(int statusCode, String contentType, byte[] body) {
this.statusCode = statusCode;
this.contentType = contentType;
this.body = body;
}
}
}

View File

@ -0,0 +1,41 @@
package cz.trask.adfsauthms.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public class TokenPayloadIdp {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("expires_in")
private int expiresIn;
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
public int getExpiresIn() {
return expiresIn;
}
public void setExpiresIn(int expiresIn) {
this.expiresIn = expiresIn;
}
}

View File

@ -0,0 +1,285 @@
package cz.trask.adfsauthms.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Authenticator;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.Proxy;
import java.net.URI;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import cz.trask.adfsauthms.config.AdfsConfig;
import cz.trask.adfsauthms.dto.TokenPayloadIdp;
public class AdfsTokenService {
private static final Logger logger = LogManager.getLogger(AdfsTokenService.class);
private static final String PARAM_RESOURCE = "resource";
private static final String PARAM_CLIENT_ID = "client_id";
private static final String PARAM_CLIENT_ASSERTION_TYPE = "client_assertion_type";
private static final String PARAM_CLIENT_ASSERTION = "client_assertion";
private static final String PARAM_GRANT_TYPE = "grant_type";
private static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
private static final String CLIENT_ASSERTION_TYPE =
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
private static final String HEADER_CONTENT_TYPE = "Content-Type";
private static final String CONTENT_TYPE_FORM = "application/x-www-form-urlencoded";
private static final String METHOD_POST = "POST";
private final AdfsConfig config;
private final ObjectMapper objectMapper;
private volatile TokenPayloadIdp cachedToken;
public AdfsTokenService(AdfsConfig config, ObjectMapper objectMapper) {
this.config = config;
this.objectMapper = objectMapper;
}
public synchronized TokenPayloadIdp getToken(String clientIdOverride) throws Exception {
validateConfig();
if (clientIdOverride == null && cachedToken != null && cachedToken.getAccessToken() != null
&& !cachedToken.getAccessToken().isBlank()
&& !isJwtExpired(cachedToken.getAccessToken())) {
logger.debug("Returning cached ADFS token");
return cachedToken;
}
logger.info("Fetching new ADFS token from {} (Client ID: {})", config.getTokenUrl(),
clientIdOverride != null ? clientIdOverride : getDefaultClientId());
TokenPayloadIdp fetchedToken = fetchToken(clientIdOverride);
if (clientIdOverride == null) {
this.cachedToken = fetchedToken;
}
return fetchedToken;
}
private String getDefaultClientId() {
if (config.getClientIds() != null && !config.getClientIds().isEmpty()) {
return config.getClientIds().get(0);
}
return null;
}
public synchronized void invalidateCache() {
logger.debug("Invalidating cached ADFS token");
this.cachedToken = null;
}
private TokenPayloadIdp fetchToken(String clientIdOverride) throws Exception {
Proxy proxy = buildProxy();
if (proxy != null) {
logger.debug("Using proxy for ADFS token fetch: {}", config.getProxyHost());
}
String effectiveClientId = clientIdOverride != null ? clientIdOverride : getDefaultClientId();
String clientAssertion = generateJwtAssertion(effectiveClientId);
logger.debug("Generated JWT client assertion for ADFS: {}", clientAssertion);
String formData = buildFormData(effectiveClientId, clientAssertion);
HttpURLConnection connection = null;
try {
URL url = new URI(config.getTokenUrl()).toURL();
connection = (HttpURLConnection) url.openConnection(proxy != null ? proxy : Proxy.NO_PROXY);
connection.setRequestMethod(METHOD_POST);
connection.setDoInput(true);
connection.setDoOutput(true);
connection.setRequestProperty(HEADER_CONTENT_TYPE, CONTENT_TYPE_FORM);
try (OutputStream outputStream = connection.getOutputStream()) {
outputStream.write(formData.getBytes(StandardCharsets.UTF_8));
}
int responseCode = connection.getResponseCode();
logger.debug("ADFS response code: {}", responseCode);
InputStream responseStream = responseCode >= 400 ? connection.getErrorStream() : connection.getInputStream();
String responseBody = readFully(responseStream);
if (responseCode >= 400) {
logger.debug("ADFS error body: {}", responseBody);
throw new IllegalStateException("ADFS returned HTTP " + responseCode + ": " + responseBody);
}
logger.debug("ADFS token fetched successfully");
return objectMapper.readValue(responseBody, TokenPayloadIdp.class);
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private Proxy buildProxy() {
if (isBlank(config.getProxyHost()) || isBlank(config.getProxyPort())) {
return null;
}
int port = Integer.parseInt(config.getProxyPort());
if (!isBlank(config.getProxyUser()) && !isBlank(config.getProxyPassword())) {
Authenticator.setDefault(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(config.getProxyUser(), config.getProxyPassword().toCharArray());
}
});
}
return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(config.getProxyHost(), port));
}
private String buildFormData(String clientId, String clientAssertion) throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put(PARAM_RESOURCE, config.getResource());
params.put(PARAM_CLIENT_ID, clientId);
params.put(PARAM_CLIENT_ASSERTION_TYPE, CLIENT_ASSERTION_TYPE);
params.put(PARAM_CLIENT_ASSERTION, clientAssertion);
params.put(PARAM_GRANT_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS);
StringBuilder builder = new StringBuilder();
for (Map.Entry<String, String> entry : params.entrySet()) {
if (builder.length() > 0) {
builder.append('&');
}
builder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8.name()));
builder.append('=');
builder.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8.name()));
}
return builder.toString();
}
private String generateJwtAssertion(String clientId) throws Exception {
String certPem = new String(Base64.getDecoder().decode(config.getCertificate().replaceAll("\\s+", "")), StandardCharsets.UTF_8)
.replace("-----BEGIN CERTIFICATE-----", "")
.replace("-----END CERTIFICATE-----", "")
.replaceAll("\\s+", "");
X509Certificate cert = getCertificate(certPem);
String keyPem = new String(Base64.getDecoder().decode(config.getPrivateKey().replaceAll("\\s+", "")), StandardCharsets.UTF_8)
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
PrivateKey privateKey = getPrivateKey(keyPem);
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
String x5t = Base64.getUrlEncoder().withoutPadding().encodeToString(sha1.digest(cert.getEncoded()));
long now = System.currentTimeMillis() / 1000L;
Map<String, Object> header = new LinkedHashMap<>();
header.put("alg", "RS256");
header.put("typ", "JWT");
header.put("x5t", x5t);
header.put("kid", x5t);
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("iss", clientId);
claims.put("sub", clientId);
claims.put("aud", config.getAudience());
claims.put("jti", UUID.randomUUID().toString());
claims.put("iat", now);
claims.put("nbf", now);
claims.put("exp", now + 600);
String headerPart = Base64.getUrlEncoder().withoutPadding()
.encodeToString(objectMapper.writeValueAsBytes(header));
String claimsPart = Base64.getUrlEncoder().withoutPadding()
.encodeToString(objectMapper.writeValueAsBytes(claims));
String signingInput = headerPart + "." + claimsPart;
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(signingInput.getBytes(StandardCharsets.UTF_8));
String signaturePart = Base64.getUrlEncoder().withoutPadding().encodeToString(signature.sign());
return signingInput + "." + signaturePart;
}
private boolean isJwtExpired(String jwt) throws Exception {
String[] parts = jwt.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("Invalid JWT format");
}
byte[] decodedBytes = Base64.getUrlDecoder().decode(parts[1]);
Map<String, Object> claims = objectMapper.readValue(decodedBytes, Map.class);
Number exp = (Number) claims.get("exp");
if (exp == null) {
throw new IllegalArgumentException("JWT does not contain 'exp' claim");
}
return exp.longValue() * 1000L < System.currentTimeMillis();
}
private X509Certificate getCertificate(String certificatePem) throws Exception {
String normalized = certificatePem.replace("-----BEGIN CERTIFICATE-----", "")
.replace("-----END CERTIFICATE-----", "")
.replaceAll("\\s", "");
byte[] certBytes = Base64.getDecoder().decode(normalized);
CertificateFactory factory = CertificateFactory.getInstance("X.509");
return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certBytes));
}
private PrivateKey getPrivateKey(String privateKeyPem) throws Exception {
String normalized = privateKeyPem.replaceAll("-----BEGIN (.*) PRIVATE KEY-----", "")
.replaceAll("-----END (.*) PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(normalized);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(spec);
}
private String readFully(InputStream inputStream) throws Exception {
if (inputStream == null) {
return "";
}
try (InputStream in = inputStream; ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
for (int read = in.read(buffer); read >= 0; read = in.read(buffer)) {
baos.write(buffer, 0, read);
}
return baos.toString(StandardCharsets.UTF_8.name());
}
}
private void validateConfig() {
requireValue(config.getTokenUrl(), "tokenUrl");
requireValue(config.getAudience(), "audience");
requireValue(config.getResource(), "resource");
if (config.getClientIds() == null || config.getClientIds().isEmpty()) {
throw new IllegalArgumentException("Missing or empty config value: clientIds");
}
requireValue(config.getCertificate(), "certificate");
requireValue(config.getPrivateKey(), "privateKey");
}
private void requireValue(String value, String name) {
if (isBlank(value)) {
throw new IllegalArgumentException("Missing or empty config value: " + name);
}
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}

View File

@ -0,0 +1,25 @@
server:
type: http
port: 8080
threads: 8
contexts:
context:
- path: /process
class: cz.trask.adfsauthms.context.ProcessHandler
- path: /health
class: cz.trask.adfsauthms.context.HealthHandler
adfs:
tokenUrl: "https://fs.komercpoj.loc/adfs/oauth2/token"
audience: "https://fs.komercpoj.loc/adfs/oauth2/token"
resource: "urn:kamma:api"
clientIds:
- "db2edf05-7af2-4523-97f9-39f930afc634"
certificate: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURFRENDQWZpZ0F3SUJBZ0lRRjhOQTNUQytVWUpJL1VRNTNTSFVGakFOQmdrcWhraUc5dzBCQVFVRkFEQWIKTVJrd0Z3WURWUVFEREJCUFFYVjBhQ0JEYkdsbGJuUWdTbGRVTUI0WERUSTJNRFl6TURFME1UazBOVm9YRFRJMwpNRFl6TURFME16azBOVm93R3pFWk1CY0dBMVVFQXd3UVQwRjFkR2dnUTJ4cFpXNTBJRXBYVkRDQ0FTSXdEUVlKCktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQU1UVzFpL0tNUFY1cTl2TXgzTGV4V1lsNFdFTmJ6UDAKQVV5ZjhlNm1GcHdEcE5PWUVXV1RQYnAwUm5HRmY4SGpCaUFwTWFDSFZCbmJqMC81Q3pSWWk0N3lWWEMzaGRiQgpYdDdZRGNCeEduYnN5Zy9LdTB5SFRQRW5YblB5VCtpYXUzS2JqQXgyOGU1OXhzRHlFMzZxaktveGRTbURZT0M4CmpzM0hsMHNGTWNjd3JzZ0JxOFEyYllIVUt1TERORmRveE1DaEhOTHlxOE1sNFdnNmVHdWtMN2p4R0tGNXlzczEKM096emJoZkc5dEpWY290WUt0WDlHUThUckdpREF2UGFOREs2WXMxUTZWbDNKVjY5dmdET1ZSVlJCUzhpWmdLaQpQbTdob1hHb2s3VVEzV2R0c3dpU09ReE1iMWZhVDFLOWp0T3lmTm1vNjgyUkluU1VJSi9sTm9rQ0F3RUFBYU5RCk1FNHdEZ1lEVlIwUEFRSC9CQVFEQWdXZ01CMEdBMVVkSlFRV01CUUdDQ3NHQVFVRkJ3TUNCZ2dyQmdFRkJRY0QKQVRBZEJnTlZIUTRFRmdRVTlRNUJSVnpRVDVNbG5mY3N6VzFwUHZiZmRud3dEUVlKS29aSWh2Y05BUUVGQlFBRApnZ0VCQUpUMVlIYk03UCtrYi9wSmJ5bENLUUd6OEZ6eGFpeDVyay9MdndJNDVmcVJTQ0NUaERmemUrdTNiK05ZCjcxV2V0Z2Y2cUhubkVrdkRnVXJyWXU1QUhrZE9WWXlDUkJnN0dWKzIrQUtpWExEYUsrV0FnM2U4Zkk2cFNvOUMKeXdvc0lNNFcyaWJWSnhzVWNIZGhTUFBVVlkrVzNSY1B4VlZmWmV4eTZXUXBsVlV0Z3JYQmpYSEdEWE5YQUw3awo4ZUFwSEpDQVByKytabktQc3VzSWFyeVNqRXRrZXR0UHViMGp0MnJlajdqaHJuU1EyRlg4czhqcU43L0lCeWRlCjd2N2YwMlowMjVPV2hZRHozRVVQaWZLOXpmM2Nac2hZRDJ3OE1jdGVUZkZKaUxPMzM2bGhCRjBNVW5laDFEaDQKOWhSVEp2NVUwNXd3bnRRbWc3bloxOFpJRGNFPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
privateKey: "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRREUxdFl2eWpEMWVhdmIKek1keTNzVm1KZUZoRFc4ejlBRk1uL0h1cGhhY0E2VFRtQkZsa3oyNmRFWnhoWC9CNHdZZ0tUR2doMVFaMjQ5UAorUXMwV0l1TzhsVnd0NFhXd1Y3ZTJBM0FjUnAyN01vUHlydE1oMHp4SjE1ejhrL29tcnR5bTR3TWR2SHVmY2JBCjhoTitxb3lxTVhVcGcyRGd2STdOeDVkTEJUSEhNSzdJQWF2RU5tMkIxQ3Jpd3pSWGFNVEFvUnpTOHF2REplRm8KT25ocnBDKzQ4UmloZWNyTE5kenM4MjRYeHZiU1ZYS0xXQ3JWL1JrUEU2eG9nd0x6MmpReXVtTE5VT2xaZHlWZQp2YjRBemxVVlVRVXZJbVlDb2o1dTRhRnhxSk8xRU4xbmJiTUlramtNVEc5WDJrOVN2WTdUc256WnFPdk5rU0owCmxDQ2Y1VGFKQWdNQkFBRUNnZ0VBTlk3ZktKR3pVSmVTazNQY3NQeThhVmJWUkRzTGp1OU5pelBaK0QxbzJYcUUKVnE2QnpVbUsramk1RWhwbzhMQjg4ak5ETlpLYzU0dytLbHh4R0FVZnMvMXNFZ2RkZTFtU0hzQmF2WW9JMFdNTQpkOCtKdHdENWhvUlh4dVNmcVlLd2pqYVVuSytnbEd2VXNKc3Rnc0dWRkpud2J5TTRNTzRkVFJDSnNmZnYyMnFCClNVRVh2MVFzRmhlbTBySnFuendPRlpycXVObXlvSnhTUzREYUJ4bmtqY3VHVG90Mk9qMlpVRjBTZW5HaU16NVkKRjZkR2dtWHNDTXBtc1k2czBrRUtDcExGMXIwS2VaUG8vZmVheU1GUDdONUtxVmxUSWExVGJjS2dQaWJFMGRqNwpXckhwR0l0Rm40ZHhJc2FCQlJ2dTlkZ2JJTVJuc0NhOFE1bURwbVg1SVFLQmdRRFVJQndFZjd1S0JqQ0M3OFFHClZzTmRLY1RuWTRrVDVxOFZiRlpVdkI2M052N0NDYmVCc1d0ZDRSMDFNUnkwVnhVUG4zekpWYk1DNUN5ckFHZ1EKTVRrVzlZQmVxb1A3aEk1ZnM5a1B5cG42SHlJN1NZS0ZldHF5RW5Ca1B4RklPSzUzRXJRWGxkZlRWbUdGdWpNUwpXYVpkOWpEOURmcGZKSzR6S2xuWHlXVlZkd0tCZ1FEdGpWVW9kNldRVEp5eHl1Vjk2Z29xbEl4L2t2UFNoQ2pqCkVqM2ZXY21HTGVBaUU2Z0lBNUh3YnFGM0tCSUVaSURpVVlJTjNkUUxLUlpVdXhDRkxpak9Ea3lBUkV1WFFUeFkKaUl0dWR3a3BmTS90OXdaQ09YSUczSWNXMDUvZHJrSzl5eXloMkRGTEJuZ3JERitoMzhDbWo0bExoc2RadDBQcgpXZjUxWnB6VC93S0JnR3d5aEpmMjN4MmowcEsyNFhHcVI3UDVYaW40Snk4emR4S2lVOWFjcmI0ZUd3dTJFUmZoCit6WERZVGFFZW5PeUIxZ1VyWDIwYkw2SXpBL2RBVGRoSkJHRjM1aHB1VEJOaUtGZ0J0TjdMOWJZa29sVEVYUXMKR2VqQ1p2bDdBY0dveDdTTW9iZDJBc1FWUjJFQ2ZKSmJqL1JWWXQ5d2hjaUoyU0RYOVVPUHdsUTVBb0dBWWp5VwpRTnZwemRqQTNBMktCaDRwQWg3WVUxR1VIelNrSy9NNVB3cEVlb2F5TDZWdFVaTVlZUk4vRm1XdHZiOUtSVTFyCnVReEpTaXc5bmVDV0hsMU9acGduTHN3UGJvZDl5eWI4Y2p3cnY4cHJ1bjd6U2FPejhmNTBwdzN4Q0oydDRBc0wKZEFxUnAvTU84czUxSmQ0QUwyRWdaK2xldTAwOGV6R0dOMHF1QkVVQ2dZRUF1VDZRS0FRUndxeWV4WlZPbmJPZwowZWVtelJSWDJYQStVV3J6YTRKbzV2MnZhYTR0cXhuQ2prQ1BaTldTc0diWUlTZVJNUUV0UXEwdUl0ZW9zMTFWCjJWM25BNXhwZ1ErbmZWNnpyQTFYK3VGekltWHpTbWgxSkpkK0RGUVhNT2xBWWNGY2J2aFdYWUVoaS9SR01DT3kKdU1MNWxBa2swV2JXRHFicFNGM1RMMlk9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0="
proxyHost: ""
proxyPort: ""
proxyUser: ""
proxyPassword: ""
backendUrl: "https://calc.kamma.cz/add?x=543&y=123"

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="debug">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>