Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC ID token auto-refresh support #11771

Merged
merged 1 commit into from
Sep 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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