🚧 Add refresh grant

This commit is contained in:
Andreas Dinauer 2026-04-12 13:57:56 +02:00
parent 308ec0c93a
commit 1aebd3b50c
10 changed files with 194 additions and 38 deletions

View File

@ -1,10 +1,16 @@
package dev.dinauer.oidcproxy; 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.Cookie;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse; import io.vertx.core.http.HttpServerResponse;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.util.Set;
@ApplicationScoped @ApplicationScoped
public class LogoutService public class LogoutService
{ {
@ -13,12 +19,33 @@ public class LogoutService
@ConfigProperty(name = "oidc.proxy.logout.redirect.url") @ConfigProperty(name = "oidc.proxy.logout.redirect.url")
String logoutRedirectUrl; 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("session", EMPTY).setMaxAge(0).setPath("/").setHttpOnly(true).setSecure(true));
response.addCookie(Cookie.cookie("identity", EMPTY).setMaxAge(0).setPath("/").setHttpOnly(false).setSecure(true)); response.addCookie(Cookie.cookie("identity", EMPTY).setMaxAge(0).setPath("/").setHttpOnly(false).setSecure(true));
response.setStatusCode(302); response.setStatusCode(302);
response.putHeader("Location", logoutRedirectUrl); response.putHeader("Location", logoutRedirectUrl);
response.send(); response.send();
} }
private String getSessionId(Set<Cookie> cookies)
{
for (Cookie cookie : cookies)
{
if ("session".equals(cookie.getName()))
{
return cookie.getValue();
}
}
return null;
}
} }

View File

@ -47,9 +47,9 @@ public class Resource
@Route(path = "/auth/logout", order = 1) @Route(path = "/auth/logout", order = 1)
@Blocking @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) @Route(path = "/api/*", order = 2)

View File

@ -30,7 +30,7 @@ public class CallbackService
{ {
String code = request.params().get("code"); String code = request.params().get("code");
TokenResponse oidcResponse = client.exchangeAuthorizationCode(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()); 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("session", sessionId).setHttpOnly(true).setSecure(true).setPath("/").setMaxAge(cookieExpiry));

View File

@ -1,6 +1,8 @@
package dev.dinauer.oidcproxy.callback; package dev.dinauer.oidcproxy.callback;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import dev.dinauer.oidcproxy.callback.model.TokenResponse; import dev.dinauer.oidcproxy.callback.model.TokenResponse;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
@ -47,7 +49,38 @@ public class OidcClient
} }
throw new RuntimeException(); 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<String> 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(); Thread.currentThread().interrupt();
throw new RuntimeException(e); throw new RuntimeException(e);

View File

@ -2,6 +2,15 @@ package dev.dinauer.oidcproxy.session;
import java.time.ZonedDateTime; 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);
}
} }

View File

@ -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;
}
}

View File

@ -0,0 +1,9 @@
package dev.dinauer.oidcproxy.session;
public class RefreshToken extends OidcToken
{
public RefreshToken(String token)
{
super(token);
}
}

View File

@ -56,36 +56,31 @@ public class SessionCache
}, 0, 30, TimeUnit.SECONDS); }, 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(); String sessionId = UUID.randomUUID().toString();
sessionService.create(sessionId, expiresAt, accessToken, refreshToken); sessionService.create(toHash(sessionId), new AccessToken(accessToken), new RefreshToken(refreshToken));
return sessionId; return sessionId;
} }
public String get(String sessionId) throws TokenNotFoundException public String get(String sessionId) throws TokenNotFoundException
{ {
String session = toHash(sessionId); String sessionHash = toHash(sessionId);
AccessToken token = tokens.get(session); AccessToken token = tokens.get(sessionHash);
if (token != null) if (token != null)
{ {
return token.token(); return token.getToken();
} }
AccessTokenEntity accessTokenEntity = accessTokenRepository.findById(session); AccessToken fromDB = sessionService.provide(sessionHash);
if (session != null) tokens.put(sessionHash, fromDB);
return fromDB.getToken();
}
public void remove(String sessionId)
{ {
try String sessionHash = toHash(sessionId);
{ tokens.remove(sessionHash);
String accessToken = encryptUtils.decrypt(accessTokenEntity.getToken()); sessionService.remove(sessionHash);
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) private String toHash(String sessionId)

View File

@ -1,5 +1,10 @@
package dev.dinauer.oidcproxy.session; 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 io.smallrye.jwt.auth.principal.DefaultJWTParser;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@ -8,6 +13,7 @@ import org.eclipse.microprofile.jwt.JsonWebToken;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.sql.Ref;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
@ -25,16 +31,16 @@ public class SessionService
@Inject @Inject
RefreshTokenRepository refreshTokenRepository; RefreshTokenRepository refreshTokenRepository;
@Inject
OidcClient client;
@Transactional @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 try
{ {
JsonWebToken token = new DefaultJWTParser().parseOnly(accessToken); accessTokenRepository.persist(new AccessTokenEntity().setId(sessionHash).setToken(encryptUtils.encrypt(accessToken.getToken())).setExpiresAt(accessToken.getExpiresAt()));
ZonedDateTime accessTokenExpiresAt = Instant.ofEpochSecond(token.getExpirationTime()).atZone(ZoneOffset.UTC); refreshTokenRepository.persist(new RefreshTokenEntity().setId(sessionHash).setToken(encryptUtils.encrypt(refreshToken.getToken())).setExpiresAt(refreshToken.getExpiresAt()));
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) catch (Exception e)
{ {
@ -42,15 +48,59 @@ public class SessionService
} }
} }
private String toHash(String sessionId) @Transactional
public AccessToken provide(String sessionHash) throws TokenNotFoundException
{
AccessTokenEntity dbToken = accessTokenRepository.findById(sessionHash);
if (isValid(dbToken))
{ {
try try
{ {
return Base64.getEncoder().encodeToString( MessageDigest.getInstance("SHA-256").digest(sessionId.getBytes())); return new AccessToken(encryptUtils.decrypt(dbToken.getToken()), dbToken.getExpiresAt());
} }
catch (NoSuchAlgorithmException e) catch (Exception e)
{ {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
RefreshTokenEntity dbRefreshToken = refreshTokenRepository.findById(sessionHash);
if (dbRefreshToken != null)
{
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()));
}
} }

View File

@ -16,6 +16,6 @@ oidc.proxy.client.redirect=http://localhost:3000
%dev,test.oidc.proxy.logout.redirect.url=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 %dev.quarkus.flyway.locations=db/migration,db/dev
quarkus.flyway.migrate-at-start=true quarkus.flyway.migrate-at-start=true