From 1aebd3b50cf7b364f9653a4285e1cc88b4197b13 Mon Sep 17 00:00:00 2001 From: Andreas Dinauer Date: Sun, 12 Apr 2026 13:57:56 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Add=20refresh=20grant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dev/dinauer/oidcproxy/LogoutService.java | 29 +++++++- .../java/dev/dinauer/oidcproxy/Resource.java | 4 +- .../oidcproxy/callback/CallbackService.java | 2 +- .../oidcproxy/callback/OidcClient.java | 35 ++++++++- .../oidcproxy/session/AccessToken.java | 11 ++- .../dinauer/oidcproxy/session/OidcToken.java | 33 +++++++++ .../oidcproxy/session/RefreshToken.java | 9 +++ .../oidcproxy/session/SessionCache.java | 35 ++++----- .../oidcproxy/session/SessionService.java | 72 ++++++++++++++++--- src/main/resources/application.properties | 2 +- 10 files changed, 194 insertions(+), 38 deletions(-) create mode 100644 src/main/java/dev/dinauer/oidcproxy/session/OidcToken.java create mode 100644 src/main/java/dev/dinauer/oidcproxy/session/RefreshToken.java diff --git a/src/main/java/dev/dinauer/oidcproxy/LogoutService.java b/src/main/java/dev/dinauer/oidcproxy/LogoutService.java index 837dc41..a436f55 100644 --- a/src/main/java/dev/dinauer/oidcproxy/LogoutService.java +++ b/src/main/java/dev/dinauer/oidcproxy/LogoutService.java @@ -1,10 +1,16 @@ package dev.dinauer.oidcproxy; +import dev.dinauer.oidcproxy.session.SessionCache; +import dev.dinauer.oidcproxy.session.SessionService; import io.vertx.core.http.Cookie; +import io.vertx.core.http.HttpServerRequest; import io.vertx.core.http.HttpServerResponse; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import java.util.Set; + @ApplicationScoped public class LogoutService { @@ -13,12 +19,33 @@ public class LogoutService @ConfigProperty(name = "oidc.proxy.logout.redirect.url") String logoutRedirectUrl; - public void logout(HttpServerResponse response) + @Inject + SessionCache sessionCache; + + public void logout(HttpServerRequest request, HttpServerResponse response) { + String sessionId = getSessionId(request.cookies()); + if (sessionId != null) + { + sessionCache.remove(sessionId); + } + response.addCookie(Cookie.cookie("session", EMPTY).setMaxAge(0).setPath("/").setHttpOnly(true).setSecure(true)); response.addCookie(Cookie.cookie("identity", EMPTY).setMaxAge(0).setPath("/").setHttpOnly(false).setSecure(true)); response.setStatusCode(302); response.putHeader("Location", logoutRedirectUrl); response.send(); } + + private String getSessionId(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/Resource.java b/src/main/java/dev/dinauer/oidcproxy/Resource.java index 2649a35..7a7572e 100644 --- a/src/main/java/dev/dinauer/oidcproxy/Resource.java +++ b/src/main/java/dev/dinauer/oidcproxy/Resource.java @@ -47,9 +47,9 @@ public class Resource @Route(path = "/auth/logout", order = 1) @Blocking - public void logout(@Context HttpServerResponse response) + public void logout(@Context RoutingContext context) { - logoutService.logout(response); + logoutService.logout(context.request(), context.response()); } @Route(path = "/api/*", order = 2) diff --git a/src/main/java/dev/dinauer/oidcproxy/callback/CallbackService.java b/src/main/java/dev/dinauer/oidcproxy/callback/CallbackService.java index 415fb45..074ac86 100644 --- a/src/main/java/dev/dinauer/oidcproxy/callback/CallbackService.java +++ b/src/main/java/dev/dinauer/oidcproxy/callback/CallbackService.java @@ -30,7 +30,7 @@ public class CallbackService { String code = request.params().get("code"); TokenResponse oidcResponse = client.exchangeAuthorizationCode(code); - String sessionId = sessionCache.add(oidcResponse.accessToken(), oidcResponse.refreshToken(), Instant.ofEpochSecond(oidcResponse.expiresAt()).atZone(ZoneOffset.UTC)); + String sessionId = sessionCache.add(oidcResponse.accessToken(), oidcResponse.refreshToken()); int cookieExpiry = (int) (JwtUtils.extractExpiresAt(oidcResponse.refreshToken()).toEpochSecond() - ZonedDateTime.now().toEpochSecond()); response.addCookie(Cookie.cookie("session", sessionId).setHttpOnly(true).setSecure(true).setPath("/").setMaxAge(cookieExpiry)); diff --git a/src/main/java/dev/dinauer/oidcproxy/callback/OidcClient.java b/src/main/java/dev/dinauer/oidcproxy/callback/OidcClient.java index 1746a3f..20d403f 100644 --- a/src/main/java/dev/dinauer/oidcproxy/callback/OidcClient.java +++ b/src/main/java/dev/dinauer/oidcproxy/callback/OidcClient.java @@ -1,6 +1,8 @@ package dev.dinauer.oidcproxy.callback; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import dev.dinauer.oidcproxy.callback.model.TokenResponse; import jakarta.enterprise.context.ApplicationScoped; @@ -47,7 +49,38 @@ public class OidcClient } throw new RuntimeException(); } - catch (IOException | InterruptedException e) + catch (IOException e) + { + throw new RuntimeException(e); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + public TokenResponse refreshAccessToken(String refreshToken) + { + String body = String.format("grant_type=refresh_token&refresh_token=%s", URLEncoder.encode(refreshToken, StandardCharsets.UTF_8)); + HttpRequest request = HttpRequest.newBuilder() + .method("POST", HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", APPLICATION_URLENCODED) + .header("Authorization", formatAuthHeader(clientId, clientSecret)).uri(URI.create(oidcAuthUrl)).build(); + try + { + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 400) + { + return OBJECT_MAPPER.readValue(response.body(), TokenResponse.class); + } + throw new RuntimeException(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); diff --git a/src/main/java/dev/dinauer/oidcproxy/session/AccessToken.java b/src/main/java/dev/dinauer/oidcproxy/session/AccessToken.java index 4f67c7d..d4c9733 100644 --- a/src/main/java/dev/dinauer/oidcproxy/session/AccessToken.java +++ b/src/main/java/dev/dinauer/oidcproxy/session/AccessToken.java @@ -2,6 +2,15 @@ package dev.dinauer.oidcproxy.session; import java.time.ZonedDateTime; -public record AccessToken(ZonedDateTime expiresAt, String token) +public class AccessToken extends OidcToken { + public AccessToken(String token) + { + super(token); + } + + public AccessToken(String token, ZonedDateTime expiresAt) + { + super(token, expiresAt); + } } diff --git a/src/main/java/dev/dinauer/oidcproxy/session/OidcToken.java b/src/main/java/dev/dinauer/oidcproxy/session/OidcToken.java new file mode 100644 index 0000000..09eb05a --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/OidcToken.java @@ -0,0 +1,33 @@ +package dev.dinauer.oidcproxy.session; + +import dev.dinauer.oidcproxy.JwtUtils; + +import java.time.ZonedDateTime; + +public abstract class OidcToken +{ + private final String token; + private final ZonedDateTime expiresAt; + + public OidcToken(String token) + { + this.token = token; + this.expiresAt = JwtUtils.extractExpiresAt(token); + } + + public OidcToken(String token, ZonedDateTime expiresAt) + { + this.token = token; + this.expiresAt = expiresAt; + } + + public ZonedDateTime getExpiresAt() + { + return expiresAt; + } + + public String getToken() + { + return token; + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/RefreshToken.java b/src/main/java/dev/dinauer/oidcproxy/session/RefreshToken.java new file mode 100644 index 0000000..f782f1c --- /dev/null +++ b/src/main/java/dev/dinauer/oidcproxy/session/RefreshToken.java @@ -0,0 +1,9 @@ +package dev.dinauer.oidcproxy.session; + +public class RefreshToken extends OidcToken +{ + public RefreshToken(String token) + { + super(token); + } +} diff --git a/src/main/java/dev/dinauer/oidcproxy/session/SessionCache.java b/src/main/java/dev/dinauer/oidcproxy/session/SessionCache.java index 1999c11..c8b9542 100644 --- a/src/main/java/dev/dinauer/oidcproxy/session/SessionCache.java +++ b/src/main/java/dev/dinauer/oidcproxy/session/SessionCache.java @@ -56,36 +56,31 @@ public class SessionCache }, 0, 30, TimeUnit.SECONDS); } - public String add(String accessToken, String refreshToken, ZonedDateTime expiresAt) + public String add(String accessToken, String refreshToken) { String sessionId = UUID.randomUUID().toString(); - sessionService.create(sessionId, expiresAt, accessToken, refreshToken); + sessionService.create(toHash(sessionId), new AccessToken(accessToken), new RefreshToken(refreshToken)); return sessionId; } public String get(String sessionId) throws TokenNotFoundException { - String session = toHash(sessionId); - AccessToken token = tokens.get(session); + String sessionHash = toHash(sessionId); + AccessToken token = tokens.get(sessionHash); if (token != null) { - return token.token(); + return token.getToken(); } - 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(); + AccessToken fromDB = sessionService.provide(sessionHash); + tokens.put(sessionHash, fromDB); + return fromDB.getToken(); + } + + public void remove(String sessionId) + { + String sessionHash = toHash(sessionId); + tokens.remove(sessionHash); + sessionService.remove(sessionHash); } private String toHash(String sessionId) diff --git a/src/main/java/dev/dinauer/oidcproxy/session/SessionService.java b/src/main/java/dev/dinauer/oidcproxy/session/SessionService.java index bbdb91b..19fd52a 100644 --- a/src/main/java/dev/dinauer/oidcproxy/session/SessionService.java +++ b/src/main/java/dev/dinauer/oidcproxy/session/SessionService.java @@ -1,5 +1,10 @@ package dev.dinauer.oidcproxy.session; +import dev.dinauer.oidcproxy.JwtUtils; +import dev.dinauer.oidcproxy.callback.OidcClient; +import dev.dinauer.oidcproxy.callback.model.TokenResponse; +import dev.dinauer.oidcproxy.proxy.exception.TokenNotFoundException; +import io.quarkus.security.UnauthorizedException; import io.smallrye.jwt.auth.principal.DefaultJWTParser; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -8,6 +13,7 @@ import org.eclipse.microprofile.jwt.JsonWebToken; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.sql.Ref; import java.time.Instant; import java.time.ZoneOffset; import java.time.ZonedDateTime; @@ -25,16 +31,16 @@ public class SessionService @Inject RefreshTokenRepository refreshTokenRepository; + @Inject + OidcClient client; + @Transactional - public void create(String sessionId, ZonedDateTime sessionExpiresAt, String accessToken, String refreshToken) + public void create(String sessionHash, AccessToken accessToken, RefreshToken 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)); + accessTokenRepository.persist(new AccessTokenEntity().setId(sessionHash).setToken(encryptUtils.encrypt(accessToken.getToken())).setExpiresAt(accessToken.getExpiresAt())); + refreshTokenRepository.persist(new RefreshTokenEntity().setId(sessionHash).setToken(encryptUtils.encrypt(refreshToken.getToken())).setExpiresAt(refreshToken.getExpiresAt())); } catch (Exception e) { @@ -42,15 +48,59 @@ public class SessionService } } - private String toHash(String sessionId) + @Transactional + public AccessToken provide(String sessionHash) throws TokenNotFoundException { - try + AccessTokenEntity dbToken = accessTokenRepository.findById(sessionHash); + if (isValid(dbToken)) { - return Base64.getEncoder().encodeToString( MessageDigest.getInstance("SHA-256").digest(sessionId.getBytes())); + try + { + return new AccessToken(encryptUtils.decrypt(dbToken.getToken()), dbToken.getExpiresAt()); + } + catch (Exception e) + { + throw new RuntimeException(e); + } } - catch (NoSuchAlgorithmException e) + RefreshTokenEntity dbRefreshToken = refreshTokenRepository.findById(sessionHash); + if (dbRefreshToken != null) { - throw new RuntimeException(e); + try + { + TokenResponse tokenResponse = client.refreshAccessToken(encryptUtils.decrypt(dbRefreshToken.getToken())); + renewAccessToken(sessionHash, new AccessToken(tokenResponse.accessToken())); + return new AccessToken(tokenResponse.accessToken()); + } + catch (Exception e) + { + throw new TokenNotFoundException(); + } + } + throw new TokenNotFoundException(); + } + + @Transactional + public void remove(String sessionHash) + { + accessTokenRepository.deleteById(sessionHash); + refreshTokenRepository.deleteById(sessionHash); + } + + private boolean isValid(AccessTokenEntity token) + { + if (token != null) + { + return ZonedDateTime.now().isBefore(token.getExpiresAt()); + } + return false; + } + + private void renewAccessToken(String sessionHash, AccessToken token) throws Exception + { + accessTokenRepository.deleteById(sessionHash); + accessTokenRepository.persist(new AccessTokenEntity().setId(sessionHash).setToken(encryptUtils.encrypt(token.getToken())).setExpiresAt(token.getExpiresAt())); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c97863c..20a5138 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,6 +16,6 @@ oidc.proxy.client.redirect=http://localhost:3000 %dev,test.oidc.proxy.logout.redirect.url=http://localhost:3000 -%dev,test.quarkus.flyway.clean-at-start=true +%dev,test.quarkus.flyway.clean-at-start=false %dev.quarkus.flyway.locations=db/migration,db/dev quarkus.flyway.migrate-at-start=true \ No newline at end of file