Skip to content

Commit

Permalink
OIDC ID token auto-refresh support
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin authored and gsmet committed Sep 1, 2020
1 parent 0255e36 commit 3ca144a
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,16 @@ public static Token fromAudience(String... audience) {
@ConfigItem
public boolean refreshExpired;

/**
* Token auto-refresh interval in seconds during the user re-authentication.
* If this option is set then the valid ID token will be refreshed if it will expire in less than a number of minutes
* set by this option. The user will still be authenticated if the ID token can no longer be refreshed but is still
* valid.
* This option will be ignored if the 'refresh-expired' property is not enabled.
*/
@ConfigItem
public Optional<Duration> autoRefreshInterval = Optional.empty();

/**
* Forced JWK set refresh interval in minutes.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,29 +112,39 @@ public SecurityIdentity apply(Throwable throwable) {
throw AuthenticationRedirectException.class.cast(throwable);
}

Throwable cause = throwable.getCause();

if (cause != null && !"expired token".equalsIgnoreCase(cause.getMessage())) {
LOG.debugf("Authentication failure: %s", cause);
throw new AuthenticationCompletionException(cause);
}
if (!configContext.oidcConfig.token.refreshExpired) {
LOG.debug("Token has expired, token refresh is not allowed");
throw new AuthenticationCompletionException(cause);
}
LOG.debug("Token has expired, trying to refresh it");
SecurityIdentity identity = trySilentRefresh(configContext,
refreshToken, context, identityProviderManager);
if (identity == null) {
LOG.debug("SecurityIdentity is null after a token refresh");
throw new AuthenticationCompletionException();
SecurityIdentity identity = null;

if (!(throwable instanceof TokenAutoRefreshException)) {
Throwable cause = throwable.getCause();

if (cause != null && !"expired token".equalsIgnoreCase(cause.getMessage())) {
LOG.debugf("Authentication failure: %s", cause);
throw new AuthenticationCompletionException(cause);
}
if (!configContext.oidcConfig.token.refreshExpired) {
LOG.debug("Token has expired, token refresh is not allowed");
throw new AuthenticationCompletionException(cause);
}
LOG.debug("Token has expired, trying to refresh it");
identity = trySilentRefresh(configContext, refreshToken, context, identityProviderManager);
if (identity == null) {
LOG.debug("SecurityIdentity is null after a token refresh");
throw new AuthenticationCompletionException();
}
} else {
identity = trySilentRefresh(configContext, refreshToken, context, identityProviderManager);
if (identity == null) {
LOG.debug("ID token can no longer be refreshed, using the current SecurityIdentity");
identity = ((TokenAutoRefreshException) throwable).getSecurityIdentity();
}
}
return identity;
}
});
}

// start a new session by starting the code flow dance
context.put("new_authentication", Boolean.TRUE);
return performCodeFlow(identityProviderManager, context, resolver);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.IdTokenCredential;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.Roles.Source;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.credential.TokenCredential;
Expand Down Expand Up @@ -108,7 +109,6 @@ public void handle(AsyncResult<AccessToken> event) {
// JWK refresh has not finished yet, but the fallback introspection request has succeeded.
tokenJson = OidcUtils.decodeJwtContent(tokenCred.getToken());
}

JsonObject userInfo = null;
if (resolvedContext.oidcConfig.authentication.isUserInfoRequired()) {
userInfo = getUserInfo(event.result(), (String) vertxContext.get("access_token"));
Expand All @@ -118,9 +118,14 @@ public void handle(AsyncResult<AccessToken> event) {
JsonObject rolesJson = getRolesJson(vertxContext, resolvedContext, tokenCred, tokenJson,
userInfo);
try {
uniEmitter.complete(
validateAndCreateIdentity(vertxContext, tokenCred, resolvedContext.oidcConfig,
tokenJson, rolesJson, userInfo));
SecurityIdentity securityIdentity = validateAndCreateIdentity(vertxContext, tokenCred,
resolvedContext.oidcConfig,
tokenJson, rolesJson, userInfo);
if (tokenAutoRefreshPrepared(tokenJson, vertxContext, resolvedContext.oidcConfig)) {
throw new TokenAutoRefreshException(securityIdentity);
} else {
uniEmitter.complete(securityIdentity);
}
} catch (Throwable ex) {
uniEmitter.fail(ex);
}
Expand Down Expand Up @@ -156,6 +161,24 @@ public String getName() {
});
}

private static boolean tokenAutoRefreshPrepared(JsonObject tokenJson, RoutingContext vertxContext,
OidcTenantConfig oidcConfig) {
if (tokenJson != null
&& oidcConfig.token.refreshExpired
&& oidcConfig.token.autoRefreshInterval.isPresent()
&& vertxContext.get("tokenAutoRefreshInProgress") != Boolean.TRUE
&& vertxContext.get("new_authentication") != Boolean.TRUE) {
final long autoRefreshInterval = oidcConfig.token.autoRefreshInterval.get().getSeconds();
final long expiry = tokenJson.getLong("exp");
final long now = System.currentTimeMillis() / 1000;
if (now + autoRefreshInterval > expiry) {
vertxContext.put("tokenAutoRefreshInProgress", Boolean.TRUE);
return true;
}
}
return false;
}

@SuppressWarnings("deprecation")
private static JsonObject getRolesJson(RoutingContext vertxContext, TenantConfigContext resolvedContext,
TokenCredential tokenCred,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.quarkus.oidc.runtime;

import io.quarkus.security.identity.SecurityIdentity;

@SuppressWarnings("serial")
public class TokenAutoRefreshException extends RuntimeException {
private SecurityIdentity securityIdentity;

public TokenAutoRefreshException(SecurityIdentity securityIdentity) {
this.securityIdentity = securityIdentity;
}

public SecurityIdentity getSecurityIdentity() {
return securityIdentity;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public String resolve(RoutingContext context) {
return "tenant-logout";
}

if (path.contains("tenant-autorefresh")) {
return "tenant-autorefresh";
}

if (path.contains("tenant-https")) {
return "tenant-https";
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.quarkus.it.keycloak;

import javax.ws.rs.GET;
import javax.ws.rs.Path;

import io.quarkus.security.Authenticated;

@Path("/tenant-autorefresh")
public class TenantAutoRefresh {
@Authenticated
@GET
public String getTenantLogout() {
return "Tenant AutoRefresh";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ quarkus.oidc.tenant-logout.logout.path=/tenant-logout/logout
quarkus.oidc.tenant-logout.logout.post-logout-path=/tenant-logout/post-logout
quarkus.oidc.tenant-logout.token.refresh-expired=true

quarkus.oidc.tenant-autorefresh.auth-server-url=${keycloak.url}/realms/logout-realm
quarkus.oidc.tenant-autorefresh.client-id=quarkus-app
quarkus.oidc.tenant-autorefresh.credentials.secret=secret
quarkus.oidc.tenant-autorefresh.application-type=web-app
quarkus.oidc.tenant-autorefresh.authentication.cookie-path=/tenant-autorefresh
quarkus.oidc.tenant-autorefresh.token.refresh-expired=true
quarkus.oidc.tenant-autorefresh.token.auto-refresh-interval=4S

# Tenant which is used to test that the redirect_uri https scheme is enforced.
quarkus.oidc.tenant-https.auth-server-url=${keycloak.url}/realms/quarkus
quarkus.oidc.tenant-https.client-id=quarkus-app
Expand All @@ -93,6 +101,9 @@ quarkus.http.auth.permission.roles1.policy=authenticated
quarkus.http.auth.permission.logout.paths=/tenant-logout
quarkus.http.auth.permission.logout.policy=authenticated

quarkus.http.auth.permission.logout.paths=/tenant-autorefresh
quarkus.http.auth.permission.logout.policy=authenticated

quarkus.http.auth.permission.https.paths=/tenant-https
quarkus.http.auth.permission.https.policy=authenticated

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,44 @@ public Boolean call() throws Exception {
page = webClient.getPage("http://localhost:8081/tenant-logout");
assertNull(getSessionCookie(webClient, "tenant-logout"));
assertEquals("Log in to logout-realm", page.getTitleText());
webClient.getCookieManager().clearCookies();
}
}

@Test
public void testTokenAutoRefresh() throws IOException {
try (final WebClient webClient = createWebClient()) {
HtmlPage page = webClient.getPage("http://localhost:8081/tenant-autorefresh");
assertEquals("Log in to logout-realm", page.getTitleText());
HtmlForm loginForm = page.getForms().get(0);
loginForm.getInputByName("username").setValueAttribute("alice");
loginForm.getInputByName("password").setValueAttribute("alice");
page = loginForm.getInputByName("login").click();
assertTrue(page.asText().contains("Tenant AutoRefresh"));

Cookie sessionCookie = getSessionCookie(webClient, "tenant-autorefresh");
assertNotNull(sessionCookie);
String idToken = getIdToken(sessionCookie);

//wait now so that we reach the refresh timeout
await().atMost(5, TimeUnit.SECONDS)
.pollInterval(Duration.ofSeconds(1))
.until(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
webClient.getOptions().setRedirectEnabled(false);
WebResponse webResponse = webClient
.loadWebResponse(
new WebRequest(URI.create("http://localhost:8081/tenant-autorefresh").toURL()));
assertEquals(200, webResponse.getStatusCode());
assertTrue(webResponse.getContentAsString().contains("Tenant AutoRefresh"));
// Should not redirect to OP but silently refresh token
Cookie newSessionCookie = getSessionCookie(webClient, "tenant-autorefresh");
assertNotNull(newSessionCookie);
return !idToken.equals(getIdToken(newSessionCookie));
}
});
webClient.getCookieManager().clearCookies();
}
}

Expand Down

0 comments on commit 3ca144a

Please sign in to comment.