🚧 improvements

This commit is contained in:
Andreas Dinauer 2026-04-06 12:08:06 +02:00
parent 4342956d06
commit bae5ede085
33 changed files with 947 additions and 222 deletions

14
pom.xml
View File

@ -43,6 +43,14 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-reactive-routes</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
@ -61,6 +69,12 @@
<version>4.5.14</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.smallrye</groupId>
<artifactId>smallrye-jwt</artifactId>
<version>4.6.3</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>

View File

@ -0,0 +1,23 @@
package dev.dinauer;
import io.smallrye.jwt.auth.principal.DefaultJWTParser;
import io.smallrye.jwt.auth.principal.ParseException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
public class JwtUtils
{
public static ZonedDateTime extractExpiresAt(String token)
{
try
{
return Instant.ofEpochSecond(new DefaultJWTParser().parseOnly(token).getExpirationTime()).atZone(ZoneOffset.UTC);
}
catch (ParseException e)
{
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,7 @@
package dev.dinauer.oidcproxy;
import java.time.ZonedDateTime;
public record AccessToken(ZonedDateTime expiresAt, String token)
{
}

View File

@ -1,9 +0,0 @@
package dev.dinauer.oidcproxy;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ForwardService
{
}

View File

@ -1,152 +0,0 @@
package dev.dinauer.oidcproxy;
import dev.dinauer.oidcproxy.callback.CallbackService;
import dev.dinauer.oidcproxy.callback.SessionRepository;
import dev.dinauer.oidcproxy.startup.ProxyRoute;
import dev.dinauer.oidcproxy.startup.RouteService;
import io.quarkus.vertx.web.Route;
import io.vertx.core.MultiMap;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.Cookie;
import io.vertx.core.http.HttpMethod;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Link;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.*;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.*;
@ApplicationScoped
public class ProxyResource
{
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
@Inject
RouteService routeService;
@Inject
SessionRepository sessionRepository;
@Inject
ForwardService forwardService;
@Inject
CallbackService callbackService;
@Route(path = "/callback", order = 0)
public void callback(@Context RoutingContext context)
{
callbackService.get(context.response(), context.request());
}
@Route(path = "/*", order = 1)
public void proxy(@Context RoutingContext context)
{
List<String> requestSegments = Arrays.stream(context.request().path().split("/")).filter(item -> !StringUtils.isBlank(item)).toList();
Optional<ProxyRoute> routeOptional = routeService.match(requestSegments);
if (routeOptional.isPresent())
{
ProxyRoute route = routeOptional.get();
LOG.info("Matched route with target '{}'", route.target());
try
{
byte[] body = extractBody(context);
HttpResponse<byte[]> response = forward(context.request().headers(), context.request().method(), body, route.strategy(), extractSession(context.request().cookies()), route.target(), concat(dropRoute(route.segments(), requestSegments)));
ResponseHandler.success(context, response);
}
catch (Exception e)
{
LOG.error("Error occurred on upstream.", e);
ResponseHandler.error(context);
}
}
else
{
LOG.error("No route found for request path '{}'", context.request().path());
ResponseHandler.notFound(context);
}
}
private byte[] extractBody(RoutingContext context)
{
if (context.body().buffer() != null)
{
return context.body().buffer().getBytes();
}
return null;
}
public HttpResponse<byte[]> forward(MultiMap headers, HttpMethod method, byte[] body, String strategy, String auth, String forwardRoot, String forwardPath) throws IOException, InterruptedException
{
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(forwardRoot + "/" + forwardPath));
if (body != null)
{
builder.method(method.name(), HttpRequest.BodyPublishers.ofByteArray(body));
}
else
{
builder.method(method.name(), HttpRequest.BodyPublishers.noBody());
}
for (Map.Entry<String, String> entry : headers.entries())
{
try
{
builder.header(entry.getKey(), entry.getValue());
}
catch (Exception e)
{
// empty
}
}
if (auth != null && Strings.CI.equals("OIDC", strategy))
{
builder.header("Authorization", String.format("Bearer %s", sessionRepository.get(auth)));
}
try(HttpClient client = HttpClient.newHttpClient())
{
return client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
}
}
private List<String> dropRoute(List<String> route, List<String> request)
{
List<String> requestSegments = new LinkedList<>(request);
for (int i = 0; i < route.size(); i++)
{
requestSegments.removeFirst();
}
return requestSegments;
}
private String concat(List<String> segments)
{
return String.join("/", segments);
}
private String extractSession(Set<Cookie> cookies)
{
for (Cookie cookie : cookies)
{
if ("session".equals(cookie.getName()))
{
return cookie.getValue();
}
}
return null;
}
}

View File

@ -1,6 +1,9 @@
package dev.dinauer.oidcproxy.callback;
import dev.dinauer.JwtUtils;
import dev.dinauer.oidcproxy.AccessToken;
import dev.dinauer.oidcproxy.callback.model.TokenResponse;
import dev.dinauer.oidcproxy.session.SessionCache;
import io.vertx.core.http.Cookie;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse;
@ -8,6 +11,8 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
@ApplicationScoped
@ -17,7 +22,7 @@ public class CallbackService
OidcClient client;
@Inject
SessionRepository sessionRepository;
SessionCache sessionCache;
@ConfigProperty(name = "oidc.proxy.client.redirect")
String redirectURI;
@ -25,9 +30,12 @@ public class CallbackService
public void get(HttpServerResponse response, HttpServerRequest request)
{
String code = request.params().get("code");
TokenResponse token = client.exchangeAuthorizationCode(code);
String sessionId = sessionRepository.add(token.accessToken());
response.addCookie(Cookie.cookie("session", sessionId).setHttpOnly(true).setSecure(true).setPath("/").setMaxAge((int) (token.expiresAt() - ZonedDateTime.now().toEpochSecond())));
TokenResponse oidcResponse = client.exchangeAuthorizationCode(code);
String sessionId = sessionCache.add(oidcResponse.accessToken(), oidcResponse.refreshToken(), Instant.ofEpochSecond(oidcResponse.expiresAt()).atZone(ZoneOffset.UTC));
int cookieExpiry = (int) (JwtUtils.extractExpiresAt(oidcResponse.refreshToken()).toEpochSecond() - ZonedDateTime.now().toEpochSecond());
response.addCookie(Cookie.cookie("session", sessionId).setHttpOnly(true).setSecure(true).setPath("/").setMaxAge(cookieExpiry));
response.addCookie(Cookie.cookie("identity", oidcResponse.idToken()).setSecure(true).setPath("/").setMaxAge(cookieExpiry));
response.setStatusCode(302);
response.putHeader("Location", redirectURI);
response.send();

View File

@ -41,7 +41,7 @@ public class OidcClient
try
{
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200)
if (response.statusCode() < 400)
{
return OBJECT_MAPPER.readValue(response.body(), TokenResponse.class);
}

View File

@ -1,30 +0,0 @@
package dev.dinauer.oidcproxy.callback;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@ApplicationScoped
public class SessionRepository
{
private final Map<String, String> tokens = new HashMap<>();
public String add(String token)
{
String sessionId = UUID.randomUUID().toString();
tokens.put(sessionId, token);
return sessionId;
}
public String get(String sessionId)
{
String token = tokens.get(sessionId);
if (token != null)
{
return token;
}
throw new RuntimeException();
}
}

View File

@ -2,6 +2,7 @@ package dev.dinauer.oidcproxy.callback.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record TokenResponse(@JsonProperty("access_token") String accessToken, @JsonProperty("expires_at") Long expiresAt)
public record TokenResponse(@JsonProperty("access_token") String accessToken, @JsonProperty("refresh_token") String refreshToken, @JsonProperty("id_token") String idToken, @JsonProperty("expires_at") Long expiresAt)
{
}

View File

@ -0,0 +1,68 @@
package dev.dinauer.oidcproxy.proxy;
import dev.dinauer.oidcproxy.proxy.exception.ProxyHttpException;
import dev.dinauer.oidcproxy.proxy.exception.TokenNotFoundException;
import dev.dinauer.oidcproxy.session.SessionCache;
import dev.dinauer.oidcproxy.proxy.header.HeaderFilter;
import dev.dinauer.oidcproxy.request.HttpRequestBuilder;
import io.vertx.core.http.Cookie;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.util.*;
@ApplicationScoped
public class ForwardService
{
private static final String AUTH_HEADER = "Authorization";
private static final HttpClient CLIENT = HttpClient.newHttpClient();
@Inject
SessionCache sessionCache;
@Inject
HeaderFilter headerFilter;
public HttpResponse<byte[]> send(RoutingContext context, String route, String strategy) throws IOException, InterruptedException, ProxyHttpException, TokenNotFoundException
{
HttpRequestBuilder builder = HttpRequestBuilder.create();
builder.setUri(route);
builder.setParams(context.request().params().entries());
builder.setMethod(context.request().method().toString());
builder.setHeaders(headerFilter.filter(context.request(), strategy));
builder.setBody(extractBody(context));
HttpResponse<byte[]> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() < 400)
{
return response;
}
throw new ProxyHttpException(response.statusCode());
}
private List<Map.Entry<String, String>> buildHeaders(List<Map.Entry<String, String>> request, Set<Cookie> cookies, String strategy) throws TokenNotFoundException
{
List<String> headerNames = request.stream().map(Map.Entry::getKey).toList();
if (!headerNames.contains(AUTH_HEADER) && "OIDC".equals(strategy) && cookies.size() == 1)
{
String session = cookies.iterator().next().getValue();
List<Map.Entry<String, String>> headers = new LinkedList<>(request);
headers.add(Map.entry(AUTH_HEADER, sessionCache.get(session)));
return headers;
}
return request;
}
private byte[] extractBody(RoutingContext context)
{
if (context.body().buffer() != null)
{
return context.body().buffer().getBytes();
}
return null;
}
}

View File

@ -0,0 +1,113 @@
package dev.dinauer.oidcproxy.proxy;
import dev.dinauer.oidcproxy.callback.CallbackService;
import dev.dinauer.oidcproxy.proxy.exception.ProxyHttpException;
import dev.dinauer.oidcproxy.proxy.exception.TokenNotFoundException;
import dev.dinauer.oidcproxy.session.SessionCache;
import dev.dinauer.oidcproxy.startup.PathConverter;
import dev.dinauer.oidcproxy.startup.ProxyRoute;
import dev.dinauer.oidcproxy.startup.RouteService;
import io.quarkus.vertx.web.Route;
import io.smallrye.common.annotation.Blocking;
import io.vertx.core.http.Cookie;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.http.HttpResponse;
import java.util.*;
@ApplicationScoped
public class ProxyResource
{
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
@Inject
RouteService routeService;
@Inject
ForwardService forwardService;
@Inject
CallbackService callbackService;
@Route(path = "/auth/callback", order = 0)
@Blocking
public void callback(@Context RoutingContext context)
{
callbackService.get(context.response(), context.request());
}
@Route(path = "/auth/logout", order = 1)
@Blocking
public void logout(@Context HttpServerResponse response)
{
response.addCookie(Cookie.cookie("session", "").setMaxAge(0).setPath("/").setHttpOnly(true).setSecure(true));
response.setStatusCode(302);
response.putHeader("Location", "http://localhost:3000");
response.send();
}
@Route(path = "/*", order = 2)
@Blocking
public void proxy(@Context RoutingContext context)
{
HttpServerRequest request = context.request();
List<String> requestSegments = PathConverter.toSegments(request.path());
Optional<ProxyRoute> routeOptional = routeService.match(requestSegments);
if (routeOptional.isPresent())
{
ProxyRoute route = routeOptional.get();
LOG.info("Matched route with target '{}'", route.target());
try
{
String targetPath = PathConverter.toPath(dropPrefix(route.segments(), requestSegments));
String targetURI = route.target() + targetPath;
HttpResponse<byte[]> response = forwardService.send(context, targetURI, route.strategy());
ResponseHandler.success(context, response);
}
catch (ProxyHttpException e)
{
LOG.error("Upstream returned error status {}.", e.getStatusCode(), e);
ResponseHandler.error(context, e.getStatusCode());
}
catch (TokenNotFoundException e)
{
LOG.error("Token not found.", e);
ResponseHandler.error(context, 401);
}
catch (InterruptedException e)
{
LOG.error("Proxy request was interrupted, returning 503.", e);
Thread.currentThread().interrupt();
ResponseHandler.error(context, 503);
}
catch (Exception e)
{
LOG.error("Error occurred on upstream.", e);
ResponseHandler.error(context, 502);
}
}
else
{
LOG.error("No route found for request path '{}'", context.request().path());
ResponseHandler.notFound(context);
}
}
private List<String> dropPrefix(List<String> route, List<String> request)
{
for (int i = 0; i < route.size(); i++)
{
request.removeFirst();
}
return request;
}
}

View File

@ -1,4 +1,4 @@
package dev.dinauer.oidcproxy;
package dev.dinauer.oidcproxy.proxy;
import io.vertx.core.buffer.Buffer;
import io.vertx.ext.web.RoutingContext;
@ -27,9 +27,9 @@ public class ResponseHandler
context.response().send(Buffer.buffer(response.body()));
}
public static void error(RoutingContext context)
public static void error(RoutingContext context, int statusCode)
{
context.response().setStatusCode(502);
context.response().setStatusCode(statusCode);
context.response().send();
}

View File

@ -0,0 +1,16 @@
package dev.dinauer.oidcproxy.proxy.exception;
public class ProxyHttpException extends Exception
{
private final int statusCode;
public ProxyHttpException(int statusCode)
{
this.statusCode = statusCode;
}
public int getStatusCode()
{
return statusCode;
}
}

View File

@ -0,0 +1,9 @@
package dev.dinauer.oidcproxy.proxy.exception;
public class TokenNotFoundException extends Exception
{
public TokenNotFoundException()
{
super();
}
}

View File

@ -0,0 +1,62 @@
package dev.dinauer.oidcproxy.proxy.header;
import dev.dinauer.oidcproxy.proxy.exception.TokenNotFoundException;
import dev.dinauer.oidcproxy.proxy.header.strategy.OidcStrategy;
import dev.dinauer.oidcproxy.session.SessionCache;
import io.quarkus.security.UnauthorizedException;
import io.vertx.core.http.Cookie;
import io.vertx.core.http.HttpServerRequest;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@ApplicationScoped
public class HeaderFilter
{
private static final List<String> HOP2HOP = List.of("Keep-Alive", "Transfer-Encoding", "TE", "Connection", "Trailer", "Upgrade", "Proxy-Authenticate", "Proxy-Authorization");
@Inject
SessionCache sessionCache;
@Inject
OidcStrategy oidcStrategy;
public List<Map.Entry<String, String>> filter(HttpServerRequest request, String strategy) throws TokenNotFoundException
{
List<Map.Entry<String, String>> headers = filterHop2HopHeaders(request.headers().entries());
if ("OIDC".equals(strategy))
{
headers = oidcStrategy.filter(getAccessToken(request), headers);
}
return headers;
}
private String getAccessToken(HttpServerRequest request) throws TokenNotFoundException
{
for (Cookie cookie : request.cookies())
{
if ("session".equals(cookie.getName()))
{
String session = cookie.getValue();
return sessionCache.get(session);
}
}
throw new UnauthorizedException();
}
private List<Map.Entry<String, String>> filterHop2HopHeaders(List<Map.Entry<String, String>> input)
{
List<Map.Entry<String, String>> result = new LinkedList<>();
for (Map.Entry<String, String> header : input)
{
if (!HOP2HOP.contains(header.getKey()))
{
result.add(header);
}
}
return result;
}
}

View File

@ -0,0 +1,15 @@
package dev.dinauer.oidcproxy.proxy.header.strategy;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Map;
@ApplicationScoped
public class NoneStrategy
{
public List<Map.Entry<String, String>> filter(List<Map.Entry<String, String>> input)
{
return input;
}
}

View File

@ -0,0 +1,35 @@
package dev.dinauer.oidcproxy.proxy.header.strategy;
import jakarta.enterprise.context.ApplicationScoped;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.commons.lang3.Strings;
import java.util.List;
import java.util.Map;
@ApplicationScoped
public class OidcStrategy
{
private static final String AUTH_HEADER = "Authorization";
public List<Map.Entry<String, String>> filter(String jwt, List<Map.Entry<String, String>> input)
{
if (!hasAuthHeader(input))
{
input.add(Map.entry(AUTH_HEADER, String.format("Bearer %s", jwt)));
}
return input;
}
private boolean hasAuthHeader(List<Map.Entry<String, String>> input)
{
for (Map.Entry<String, String> header : input)
{
if (Strings.CI.equals(AUTH_HEADER, header.getKey()))
{
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,104 @@
package dev.dinauer.oidcproxy.request;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.net.http.HttpRequest;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class HttpRequestBuilder
{
private static final Logger LOG = LoggerFactory.getLogger(HttpRequestBuilder.class);
private String method;
private String uri;
private List<Map.Entry<String, String>> params;
private List<Map.Entry<String, String>> headers;
private byte[] body;
public static HttpRequestBuilder create()
{
return new HttpRequestBuilder();
}
public void setMethod(String method)
{
this.method = method;
}
public void setUri(String uri)
{
this.uri = uri;
}
public void setParams(List<Map.Entry<String, String>> params)
{
this.params = params;
}
public void setHeaders(List<Map.Entry<String, String>> headers)
{
this.headers = headers;
}
public void setBody(byte[] body)
{
this.body = body;
}
public HttpRequest build()
{
HttpRequest.Builder builder = HttpRequest.newBuilder();
builder.uri(buildURI(this.uri, this.params));
builder.method(method, buildBody(body));
if (this.headers != null)
{
for (Map.Entry<String, String> element : this.headers)
{
try
{
builder.setHeader(element.getKey(), element.getValue());
LOG.info("added header " + element.getKey());
}
catch (Exception e)
{
LOG.info("Failed to add header.", e);
}
}
}
return builder.build();
}
private static URI buildURI(String uri, List<Map.Entry<String, String>> params)
{
try
{
if (params != null && !params.isEmpty())
{
String queryParams = params.stream().map(entry -> String.format("%s=%s", URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8), URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))).collect(Collectors.joining("&"));
return new URI(String.format("%s?%s", uri, queryParams));
}
return new URI(uri);
}
catch (Exception e)
{
throw new RuntimeException();
}
}
private static HttpRequest.BodyPublisher buildBody(byte[] body)
{
if (body != null)
{
return HttpRequest.BodyPublishers.ofByteArray(body);
}
return HttpRequest.BodyPublishers.noBody();
}
}

View File

@ -0,0 +1,52 @@
package dev.dinauer.oidcproxy.session;
import jakarta.persistence.*;
import java.time.ZonedDateTime;
@Entity
@Table(name = "access_token")
public class AccessTokenEntity
{
@Id
private String id;
@Column(name = "expires_at")
private ZonedDateTime expiresAt;
@Column(columnDefinition = "text")
private String token;
public String getId()
{
return id;
}
public AccessTokenEntity setId(String id)
{
this.id = id;
return this;
}
public ZonedDateTime getExpiresAt()
{
return expiresAt;
}
public AccessTokenEntity setExpiresAt(ZonedDateTime expiresAt)
{
this.expiresAt = expiresAt;
return this;
}
public String getToken()
{
return token;
}
public AccessTokenEntity setToken(String token)
{
this.token = token;
return this;
}
}

View File

@ -0,0 +1,17 @@
package dev.dinauer.oidcproxy.session;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
@ApplicationScoped
public class AccessTokenRepository implements PanacheRepositoryBase<AccessTokenEntity, String>
{
public List<AccessTokenEntity> findExpiresBefore(ZonedDateTime timestamp)
{
return list("expiresAt <= :timestamp", Map.ofEntries(Map.entry("timestamp", timestamp)));
}
}

View File

@ -0,0 +1,71 @@
package dev.dinauer.oidcproxy.session;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.util.Base64;
@ApplicationScoped
public class EncryptUtils
{
private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding";
private static final int IV_LENGTH = 8;
private static final int AUTH_TAG_LENGTH = 128;
private final SecretKey key;
public EncryptUtils(@ConfigProperty(name = "oidc.proxy.crypto.secret") String secret)
{
try
{
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
KeySpec spec = new PBEKeySpec(secret.toCharArray(), secret.getBytes(), 256_000, 256);
this.key = new SecretKeySpec(factory.generateSecret(spec).getEncoded(), "AES");
}
catch (NoSuchAlgorithmException | InvalidKeySpecException e)
{
throw new RuntimeException(e);
}
}
public String encrypt(String input) throws Exception
{
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(AUTH_TAG_LENGTH, iv));
byte[] encrypted = cipher.doFinal(input.getBytes((StandardCharsets.UTF_8)));
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
}
public String decrypt(String ciphertext) throws Exception
{
byte[] combined = Base64.getDecoder().decode(ciphertext);
byte[] iv = new byte[IV_LENGTH];
byte[] encrypted = new byte[combined.length - IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, iv.length);
System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(AUTH_TAG_LENGTH, iv));
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
}
}

View File

@ -0,0 +1,52 @@
package dev.dinauer.oidcproxy.session;
import jakarta.persistence.*;
import java.time.ZonedDateTime;
@Entity
@Table(name = "refresh_token")
public class RefreshTokenEntity
{
@Id
private String id;
@Column(name = "expires_at")
private ZonedDateTime expiresAt;
@Column(columnDefinition = "text")
private String token;
public String getId()
{
return id;
}
public RefreshTokenEntity setId(String id)
{
this.id = id;
return this;
}
public ZonedDateTime getExpiresAt()
{
return expiresAt;
}
public RefreshTokenEntity setExpiresAt(ZonedDateTime expiresAt)
{
this.expiresAt = expiresAt;
return this;
}
public RefreshTokenEntity setToken(String token)
{
this.token = token;
return this;
}
public String getToken()
{
return token;
}
}

View File

@ -0,0 +1,17 @@
package dev.dinauer.oidcproxy.session;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
@ApplicationScoped
public class RefreshTokenRepository implements PanacheRepositoryBase<RefreshTokenEntity, String>
{
public List<RefreshTokenEntity> findExpiresBefore(ZonedDateTime timestamp)
{
return list("expiresAt <= :timestamp", Map.ofEntries(Map.entry("timestamp", timestamp)));
}
}

View File

@ -0,0 +1,104 @@
package dev.dinauer.oidcproxy.session;
import dev.dinauer.oidcproxy.AccessToken;
import dev.dinauer.oidcproxy.proxy.exception.TokenNotFoundException;
import io.quarkus.narayana.jta.QuarkusTransaction;
import io.quarkus.runtime.Startup;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@ApplicationScoped
public class SessionCache
{
private final Map<String, AccessToken> tokens = new ConcurrentHashMap<>();
@Inject
Logger LOG;
@Inject
SessionService sessionService;
@Inject
EncryptUtils encryptUtils;
@Inject
AccessTokenRepository accessTokenRepository;
@Startup
@ActivateRequestContext
void housekeeping()
{
Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
LOG.info("Running housekeeping...");
List<AccessTokenEntity> sessions = accessTokenRepository.findExpiresBefore(ZonedDateTime.now().plusMinutes(2));
for (AccessTokenEntity session : sessions)
{
QuarkusTransaction.begin();
tokens.remove(session.getId());
try
{
accessTokenRepository.delete(session);
QuarkusTransaction.commit();
}
catch (Exception e)
{
QuarkusTransaction.rollback();
}
}
}, 0, 30, TimeUnit.SECONDS);
}
public String add(String accessToken, String refreshToken, ZonedDateTime expiresAt)
{
String sessionId = UUID.randomUUID().toString();
sessionService.create(sessionId, expiresAt, accessToken, refreshToken);
return sessionId;
}
public String get(String sessionId) throws TokenNotFoundException
{
String session = toHash(sessionId);
AccessToken token = tokens.get(session);
if (token != null)
{
return token.token();
}
AccessTokenEntity accessTokenEntity = accessTokenRepository.findById(session);
if (session != null)
{
try
{
String accessToken = encryptUtils.decrypt(accessTokenEntity.getToken());
tokens.put(session, new AccessToken(accessTokenEntity.getExpiresAt(), accessToken));
return accessToken;
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
throw new TokenNotFoundException();
}
private String toHash(String sessionId)
{
try
{
return Base64.getEncoder().encodeToString( MessageDigest.getInstance("SHA-256").digest(sessionId.getBytes()));
}
catch (NoSuchAlgorithmException e)
{
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,57 @@
package dev.dinauer.oidcproxy.session;
import dev.dinauer.oidcproxy.AccessToken;
import io.smallrye.jwt.auth.principal.DefaultJWTParser;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Base64;
@ApplicationScoped
public class SessionService
{
@Inject
EncryptUtils encryptUtils;
@Inject
AccessTokenRepository accessTokenRepository;
@Inject
RefreshTokenRepository refreshTokenRepository;
@Transactional
public void create(String sessionId, ZonedDateTime sessionExpiresAt, String accessToken, String refreshToken)
{
String sessionHash = toHash(sessionId);
try
{
JsonWebToken token = new DefaultJWTParser().parseOnly(accessToken);
ZonedDateTime accessTokenExpiresAt = Instant.ofEpochSecond(token.getExpirationTime()).atZone(ZoneOffset.UTC);
accessTokenRepository.persist(new AccessTokenEntity().setId(sessionHash).setToken(encryptUtils.encrypt(accessToken)).setExpiresAt(accessTokenExpiresAt));
refreshTokenRepository.persist(new RefreshTokenEntity().setId(sessionHash).setToken(encryptUtils.encrypt(refreshToken)).setExpiresAt(sessionExpiresAt));
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
private String toHash(String sessionId)
{
try
{
return Base64.getEncoder().encodeToString( MessageDigest.getInstance("SHA-256").digest(sessionId.getBytes()));
}
catch (NoSuchAlgorithmException e)
{
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,42 @@
package dev.dinauer.oidcproxy.startup;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import javax.swing.*;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
public class PathConverter
{
private static final String SEPARATOR = "/";
public static List<String> toSegments(String path)
{
if (StringUtils.isBlank(path))
{
return new LinkedList<>();
}
return new LinkedList<>(Arrays.stream(path.split(SEPARATOR)).filter(item -> !StringUtils.isBlank(item)).toList());
}
public static String toPath(List<String> segments)
{
if (isEmpty(segments))
{
return SEPARATOR;
}
return SEPARATOR + String.join(SEPARATOR, segments);
}
public static String normalize(String path)
{
return Strings.CI.removeEnd(path, SEPARATOR);
}
private static boolean isEmpty(List<String> list)
{
return list == null || list.isEmpty();
}
}

View File

@ -1,25 +1,21 @@
package dev.dinauer.oidcproxy.startup;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import java.util.List;
import java.util.stream.Stream;
public record ProxyRoute(String path, String target, String strategy)
{
public String path()
{
return Strings.CI.removeEnd(target, "/");
return PathConverter.normalize(this.path);
}
public String target()
{
return Strings.CI.removeEnd(target, "/");
return PathConverter.normalize(this.target);
}
public List<String> segments()
{
return Stream.of(path.split("/")).filter(item -> !StringUtils.isBlank(item)).toList();
return PathConverter.toSegments(this.path);
}
}

View File

@ -37,6 +37,10 @@ public class RouteService
List<ProxyRoute> result = new LinkedList<>();
for (ConfigRoute route : rules.routes())
{
if (StringUtils.isBlank(route.strategy()))
{
throw new IllegalArgumentException();
}
if (StringUtils.isBlank(rules.root()))
{
result.add(new ProxyRoute(route.path(), route.target(), route.strategy()));

View File

@ -1,5 +1,6 @@
package dev.dinauer.oidcproxy.startup.model;
import dev.dinauer.oidcproxy.startup.PathConverter;
import org.apache.commons.lang3.StringUtils;
import java.util.List;
@ -8,10 +9,6 @@ public record ConfigRoute(String path, String target, String strategy)
{
public List<String> segments()
{
if (StringUtils.isBlank(path))
{
return List.of();
}
return List.of(path.split("/"));
return PathConverter.toSegments(this.path);
}
}

View File

@ -1,6 +1,6 @@
package dev.dinauer.oidcproxy.startup.model;
import org.apache.commons.lang3.StringUtils;
import dev.dinauer.oidcproxy.startup.PathConverter;
import java.util.List;
@ -8,10 +8,6 @@ public record ConfigRules(String root, List<ConfigRoute> routes)
{
public List<String> segments()
{
if (StringUtils.isBlank(root))
{
return List.of();
}
return List.of(root.split("/"));
return PathConverter.toSegments(this.root);
}
}

View File

@ -3,5 +3,13 @@ oidc.proxy.client.id=backend
oidc.proxy.client.secret=backend
oidc.proxy.client.redirect=http://localhost:3000
%dev.oidc.proxy.routes.config.location=/home/andreas/Documents/dev/oidc-proxy/src/main/resources/routes.yaml
%test,dev.oidc.proxy.routes.config.location=/home/andreas/Documents/dev/oidc-proxy/src/main/resources/routes.yaml
%prod.oidc.proxy.routes.config.location=/var/lib/oidc-proxy/routes.yaml
%test,dev.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%dev,test.quarkus.datasource.username=postgres
%dev,test.quarkus.datasource.password=postgres
%dev,test.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/postgres?currentSchema=oidc-proxy
%dev,test.oidc.proxy.crypto.secret=test

View File

@ -2,6 +2,6 @@ routes:
- path: /api
target: http://localhost:8081
strategy: OIDC
- path: /
- path: /example
target: http://example.com
strategy: NONE
strategy: OIDC

View File

@ -0,0 +1,28 @@
package dev.dinauer.oidcproxy.session;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@QuarkusTest
public class EncryptUtilsTest
{
@Inject
EncryptUtils encryptUtils;
@Test
void test() throws Exception
{
String helloWorld = "Hello World!";
String encrypted = encryptUtils.encrypt(helloWorld);
String decrypted = encryptUtils.decrypt(encrypted);
Assertions.assertEquals(helloWorld, decrypted);
Assertions.assertNotEquals(helloWorld, new String(Base64.getDecoder().decode(encrypted), StandardCharsets.UTF_8));
}
}