diff --git a/pom.xml b/pom.xml index 4c40b62..1e7d895 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,14 @@ io.quarkus quarkus-reactive-routes + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-hibernate-orm-panache + org.apache.commons commons-lang3 @@ -61,6 +69,12 @@ 4.5.14 compile + + io.smallrye + smallrye-jwt + 4.6.3 + compile + io.quarkus quarkus-junit diff --git a/src/main/java/dev/dinauer/JwtUtils.java b/src/main/java/dev/dinauer/JwtUtils.java new file mode 100644 index 0000000..f453f41 --- /dev/null +++ b/src/main/java/dev/dinauer/JwtUtils.java @@ -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); + } + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/AccessToken.java b/src/main/java/dev/dinauer/oidcproxy/AccessToken.java new file mode 100644 index 0000000..a53c586 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/AccessToken.java @@ -0,0 +1,7 @@ +package dev.dinauer.oidcproxy; + +import java.time.ZonedDateTime; + +public record AccessToken(ZonedDateTime expiresAt, String token) +{ +} diff --git a/src/main/java/dev/dinauer/oidcproxy/ForwardService.java b/src/main/java/dev/dinauer/oidcproxy/ForwardService.java deleted file mode 100644 index eee5d94..0000000 --- a/src/main/java/dev/dinauer/oidcproxy/ForwardService.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.dinauer.oidcproxy; - -import jakarta.enterprise.context.ApplicationScoped; - -@ApplicationScoped -public class ForwardService -{ - -} diff --git a/src/main/java/dev/dinauer/oidcproxy/ProxyResource.java b/src/main/java/dev/dinauer/oidcproxy/ProxyResource.java deleted file mode 100644 index 3d303b8..0000000 --- a/src/main/java/dev/dinauer/oidcproxy/ProxyResource.java +++ /dev/null @@ -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 requestSegments = Arrays.stream(context.request().path().split("/")).filter(item -> !StringUtils.isBlank(item)).toList(); - - Optional 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 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 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 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 dropRoute(List route, List request) - { - List requestSegments = new LinkedList<>(request); - for (int i = 0; i < route.size(); i++) - { - requestSegments.removeFirst(); - } - return requestSegments; - } - - private String concat(List segments) - { - return String.join("/", segments); - } - - private String extractSession(Set cookies) - { - for (Cookie cookie : cookies) - { - if ("session".equals(cookie.getName())) - { - return cookie.getValue(); - } - } - return null; - } -} diff --git a/src/main/java/dev/dinauer/oidcproxy/callback/CallbackService.java b/src/main/java/dev/dinauer/oidcproxy/callback/CallbackService.java index 697ace5..7c236d4 100644 --- a/src/main/java/dev/dinauer/oidcproxy/callback/CallbackService.java +++ b/src/main/java/dev/dinauer/oidcproxy/callback/CallbackService.java @@ -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(); diff --git a/src/main/java/dev/dinauer/oidcproxy/callback/OidcClient.java b/src/main/java/dev/dinauer/oidcproxy/callback/OidcClient.java index 96d0fcc..1746a3f 100644 --- a/src/main/java/dev/dinauer/oidcproxy/callback/OidcClient.java +++ b/src/main/java/dev/dinauer/oidcproxy/callback/OidcClient.java @@ -41,7 +41,7 @@ public class OidcClient try { HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() == 200) + if (response.statusCode() < 400) { return OBJECT_MAPPER.readValue(response.body(), TokenResponse.class); } diff --git a/src/main/java/dev/dinauer/oidcproxy/callback/SessionRepository.java b/src/main/java/dev/dinauer/oidcproxy/callback/SessionRepository.java deleted file mode 100644 index 01bed7f..0000000 --- a/src/main/java/dev/dinauer/oidcproxy/callback/SessionRepository.java +++ /dev/null @@ -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 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(); - } -} diff --git a/src/main/java/dev/dinauer/oidcproxy/callback/model/TokenResponse.java b/src/main/java/dev/dinauer/oidcproxy/callback/model/TokenResponse.java index b4ed4bb..2af840b 100644 --- a/src/main/java/dev/dinauer/oidcproxy/callback/model/TokenResponse.java +++ b/src/main/java/dev/dinauer/oidcproxy/callback/model/TokenResponse.java @@ -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) { + } diff --git a/src/main/java/dev/dinauer/oidcproxy/proxy/ForwardService.java b/src/main/java/dev/dinauer/oidcproxy/proxy/ForwardService.java new file mode 100644 index 0000000..86c2f85 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/proxy/ForwardService.java @@ -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 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 response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() < 400) + { + return response; + } + throw new ProxyHttpException(response.statusCode()); + } + + private List> buildHeaders(List> request, Set cookies, String strategy) throws TokenNotFoundException + { + List 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> 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; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/proxy/ProxyResource.java b/src/main/java/dev/dinauer/oidcproxy/proxy/ProxyResource.java new file mode 100644 index 0000000..086e44e --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/proxy/ProxyResource.java @@ -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 requestSegments = PathConverter.toSegments(request.path()); + + Optional 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 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 dropPrefix(List route, List request) + { + for (int i = 0; i < route.size(); i++) + { + request.removeFirst(); + } + return request; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/ResponseHandler.java b/src/main/java/dev/dinauer/oidcproxy/proxy/ResponseHandler.java similarity index 85% rename from src/main/java/dev/dinauer/oidcproxy/ResponseHandler.java rename to src/main/java/dev/dinauer/oidcproxy/proxy/ResponseHandler.java index 302cb16..710e7ae 100644 --- a/src/main/java/dev/dinauer/oidcproxy/ResponseHandler.java +++ b/src/main/java/dev/dinauer/oidcproxy/proxy/ResponseHandler.java @@ -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(); } diff --git a/src/main/java/dev/dinauer/oidcproxy/proxy/exception/ProxyHttpException.java b/src/main/java/dev/dinauer/oidcproxy/proxy/exception/ProxyHttpException.java new file mode 100644 index 0000000..5bf34a9 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/proxy/exception/ProxyHttpException.java @@ -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; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/proxy/exception/TokenNotFoundException.java b/src/main/java/dev/dinauer/oidcproxy/proxy/exception/TokenNotFoundException.java new file mode 100644 index 0000000..a39cc86 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/proxy/exception/TokenNotFoundException.java @@ -0,0 +1,9 @@ +package dev.dinauer.oidcproxy.proxy.exception; + +public class TokenNotFoundException extends Exception +{ + public TokenNotFoundException() + { + super(); + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/proxy/header/HeaderFilter.java b/src/main/java/dev/dinauer/oidcproxy/proxy/header/HeaderFilter.java new file mode 100644 index 0000000..4799ff3 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/proxy/header/HeaderFilter.java @@ -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 HOP2HOP = List.of("Keep-Alive", "Transfer-Encoding", "TE", "Connection", "Trailer", "Upgrade", "Proxy-Authenticate", "Proxy-Authorization"); + + @Inject + SessionCache sessionCache; + + @Inject + OidcStrategy oidcStrategy; + + public List> filter(HttpServerRequest request, String strategy) throws TokenNotFoundException + { + List> 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> filterHop2HopHeaders(List> input) + { + List> result = new LinkedList<>(); + for (Map.Entry header : input) + { + if (!HOP2HOP.contains(header.getKey())) + { + result.add(header); + } + } + return result; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/proxy/header/strategy/NoneStrategy.java b/src/main/java/dev/dinauer/oidcproxy/proxy/header/strategy/NoneStrategy.java new file mode 100644 index 0000000..8a78885 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/proxy/header/strategy/NoneStrategy.java @@ -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> filter(List> input) + { + return input; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/proxy/header/strategy/OidcStrategy.java b/src/main/java/dev/dinauer/oidcproxy/proxy/header/strategy/OidcStrategy.java new file mode 100644 index 0000000..765d5e8 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/proxy/header/strategy/OidcStrategy.java @@ -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> filter(String jwt, List> input) + { + if (!hasAuthHeader(input)) + { + input.add(Map.entry(AUTH_HEADER, String.format("Bearer %s", jwt))); + } + return input; + } + + private boolean hasAuthHeader(List> input) + { + for (Map.Entry header : input) + { + if (Strings.CI.equals(AUTH_HEADER, header.getKey())) + { + return true; + } + } + return false; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/request/HttpRequestBuilder.java b/src/main/java/dev/dinauer/oidcproxy/request/HttpRequestBuilder.java new file mode 100644 index 0000000..b940f08 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/request/HttpRequestBuilder.java @@ -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> params; + private List> 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> params) + { + this.params = params; + } + + public void setHeaders(List> 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 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> 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(); + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/AccessTokenEntity.java b/src/main/java/dev/dinauer/oidcproxy/session/AccessTokenEntity.java new file mode 100644 index 0000000..5f55398 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/AccessTokenEntity.java @@ -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; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/AccessTokenRepository.java b/src/main/java/dev/dinauer/oidcproxy/session/AccessTokenRepository.java new file mode 100644 index 0000000..418bd33 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/AccessTokenRepository.java @@ -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 +{ + public List findExpiresBefore(ZonedDateTime timestamp) + { + return list("expiresAt <= :timestamp", Map.ofEntries(Map.entry("timestamp", timestamp))); + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/EncryptUtils.java b/src/main/java/dev/dinauer/oidcproxy/session/EncryptUtils.java new file mode 100644 index 0000000..fea3510 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/EncryptUtils.java @@ -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); + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/RefreshTokenEntity.java b/src/main/java/dev/dinauer/oidcproxy/session/RefreshTokenEntity.java new file mode 100644 index 0000000..be0b618 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/RefreshTokenEntity.java @@ -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; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/RefreshTokenRepository.java b/src/main/java/dev/dinauer/oidcproxy/session/RefreshTokenRepository.java new file mode 100644 index 0000000..03bc808 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/RefreshTokenRepository.java @@ -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 +{ + public List findExpiresBefore(ZonedDateTime timestamp) + { + return list("expiresAt <= :timestamp", Map.ofEntries(Map.entry("timestamp", timestamp))); + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/SessionCache.java b/src/main/java/dev/dinauer/oidcproxy/session/SessionCache.java new file mode 100644 index 0000000..222d6cf --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/SessionCache.java @@ -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 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 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); + } + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/SessionService.java b/src/main/java/dev/dinauer/oidcproxy/session/SessionService.java new file mode 100644 index 0000000..673053e --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/SessionService.java @@ -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); + } + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/startup/PathConverter.java b/src/main/java/dev/dinauer/oidcproxy/startup/PathConverter.java new file mode 100644 index 0000000..62985e9 --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/startup/PathConverter.java @@ -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 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 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 list) + { + return list == null || list.isEmpty(); + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/startup/ProxyRoute.java b/src/main/java/dev/dinauer/oidcproxy/startup/ProxyRoute.java index 3ec3314..0b7312a 100644 --- a/src/main/java/dev/dinauer/oidcproxy/startup/ProxyRoute.java +++ b/src/main/java/dev/dinauer/oidcproxy/startup/ProxyRoute.java @@ -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 segments() { - return Stream.of(path.split("/")).filter(item -> !StringUtils.isBlank(item)).toList(); + return PathConverter.toSegments(this.path); } } diff --git a/src/main/java/dev/dinauer/oidcproxy/startup/RouteService.java b/src/main/java/dev/dinauer/oidcproxy/startup/RouteService.java index 6875dfd..8f9d6c1 100644 --- a/src/main/java/dev/dinauer/oidcproxy/startup/RouteService.java +++ b/src/main/java/dev/dinauer/oidcproxy/startup/RouteService.java @@ -37,6 +37,10 @@ public class RouteService List 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())); diff --git a/src/main/java/dev/dinauer/oidcproxy/startup/model/ConfigRoute.java b/src/main/java/dev/dinauer/oidcproxy/startup/model/ConfigRoute.java index 6d4735e..67020f6 100644 --- a/src/main/java/dev/dinauer/oidcproxy/startup/model/ConfigRoute.java +++ b/src/main/java/dev/dinauer/oidcproxy/startup/model/ConfigRoute.java @@ -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 segments() { - if (StringUtils.isBlank(path)) - { - return List.of(); - } - return List.of(path.split("/")); + return PathConverter.toSegments(this.path); } } diff --git a/src/main/java/dev/dinauer/oidcproxy/startup/model/ConfigRules.java b/src/main/java/dev/dinauer/oidcproxy/startup/model/ConfigRules.java index 9c15966..61468bc 100644 --- a/src/main/java/dev/dinauer/oidcproxy/startup/model/ConfigRules.java +++ b/src/main/java/dev/dinauer/oidcproxy/startup/model/ConfigRules.java @@ -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 routes) { public List segments() { - if (StringUtils.isBlank(root)) - { - return List.of(); - } - return List.of(root.split("/")); + return PathConverter.toSegments(this.root); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8cdbca2..61a9aa9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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 -%prod.oidc.proxy.routes.config.location=/var/lib/oidc-proxy/routes.yaml \ No newline at end of file +%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 \ No newline at end of file diff --git a/src/main/resources/routes.yaml b/src/main/resources/routes.yaml index 11de261..34e2cc7 100644 --- a/src/main/resources/routes.yaml +++ b/src/main/resources/routes.yaml @@ -2,6 +2,6 @@ routes: - path: /api target: http://localhost:8081 strategy: OIDC - - path: / + - path: /example target: http://example.com - strategy: NONE \ No newline at end of file + strategy: OIDC \ No newline at end of file diff --git a/src/test/java/dev/dinauer/oidcproxy/session/EncryptUtilsTest.java b/src/test/java/dev/dinauer/oidcproxy/session/EncryptUtilsTest.java new file mode 100644 index 0000000..5b53eb6 --- /dev/null +++ b/src/test/java/dev/dinauer/oidcproxy/session/EncryptUtilsTest.java @@ -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)); + } +}