Skip to content

Commit

Permalink
Merge pull request #9891 from sberyozkin/refresh_jwk
Browse files Browse the repository at this point in the history
OIDC JWK refresh support
  • Loading branch information
sberyozkin authored Jun 15, 2020
2 parents 321917a + dcc4147 commit fd413f3
Show file tree
Hide file tree
Showing 15 changed files with 445 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package io.quarkus.oidc;

import io.quarkus.oidc.runtime.ContextAwareTokenCredential;
import io.quarkus.oidc.runtime.OidcUtils;
import io.vertx.ext.web.RoutingContext;

public class AccessTokenCredential extends ContextAwareTokenCredential {

private RefreshToken refreshToken;
private boolean opaque;

public AccessTokenCredential() {
this(null, null);
Expand All @@ -17,7 +19,7 @@ public AccessTokenCredential() {
* @param accessToken - access token
*/
public AccessTokenCredential(String accessToken, RoutingContext context) {
super(accessToken, "bearer", context);
this(accessToken, null, context);
}

/**
Expand All @@ -27,11 +29,18 @@ public AccessTokenCredential(String accessToken, RoutingContext context) {
* @param refreshToken - refresh token which can be used to refresh this access token, may be null
*/
public AccessTokenCredential(String accessToken, RefreshToken refreshToken, RoutingContext context) {
this(accessToken, context);
super(accessToken, "bearer", context);
this.refreshToken = refreshToken;
if (accessToken != null) {
this.opaque = OidcUtils.isOpaqueToken(accessToken);
}
}

public RefreshToken getRefreshToken() {
return refreshToken;
}

public boolean isOpaque() {
return opaque;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,12 @@ public static Token fromAudience(String... audience) {
@ConfigItem
public boolean refreshExpired;

/**
* Forced JWK set refresh interval in minutes.
*/
@ConfigItem(defaultValue = "10M")
public Duration forcedJwkRefreshInterval = Duration.ofMinutes(10);

public Optional<String> getIssuer() {
return issuer;
}
Expand Down Expand Up @@ -677,6 +683,14 @@ public boolean isRefreshExpired() {
public void setRefreshExpired(boolean refreshExpired) {
this.refreshExpired = refreshExpired;
}

public Duration getForcedJwkRefreshInterval() {
return forcedJwkRefreshInterval;
}

public void setForcedJwkRefreshInterval(Duration forcedJwkRefreshInterval) {
this.forcedJwkRefreshInterval = forcedJwkRefreshInterval;
}
}

@ConfigGroup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ private Uni<SecurityIdentity> performCodeFlow(IdentityProviderManager identityPr
LOG.debug("State parameter can not be empty or multi-valued");
return Uni.createFrom().failure(new AuthenticationCompletionException());
} else if (!stateCookie.getValue().startsWith(values.get(0))) {
LOG.debug("State cookie does not match the state parameter");
LOG.debug("State cookie value does not match the state query parameter value");
return Uni.createFrom().failure(new AuthenticationCompletionException());
} else if (context.queryParam("pathChecked").isEmpty()) {
// This is an original redirect from IDP, check if the request path needs to be updated
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.quarkus.oidc.runtime;

import java.time.Duration;

import org.jboss.logging.Logger;

import io.vertx.core.Handler;
import io.vertx.ext.auth.oauth2.OAuth2Auth;

public class JwkSetRefreshHandler implements Handler<String> {
private static final Logger LOG = Logger.getLogger(JwkSetRefreshHandler.class);
private OAuth2Auth auth;
private volatile long lastForcedRefreshTime;
private long forcedJwksRefreshIntervalMilliSecs;

public JwkSetRefreshHandler(OAuth2Auth auth, Duration forcedJwksRefreshInterval) {
this.auth = auth;
this.forcedJwksRefreshIntervalMilliSecs = forcedJwksRefreshInterval.toMillis();
}

@SuppressWarnings("deprecation")
@Override
public void handle(String kid) {
final long now = System.currentTimeMillis();
if (now > lastForcedRefreshTime + forcedJwksRefreshIntervalMilliSecs) {
lastForcedRefreshTime = now;
LOG.debugf("No JWK with %s key id is available, trying to refresh the JWK set", kid);
auth.loadJWK(res -> {
if (res.failed()) {
LOG.debugf("Failed to refresh the JWK set: %s", res.cause());
}
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

import static io.quarkus.oidc.runtime.OidcUtils.validateAndCreateIdentity;

import java.security.Principal;
import java.util.function.Consumer;
import java.util.function.Supplier;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.IdTokenCredential;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.credential.TokenCredential;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.subscription.UniEmitter;
import io.vertx.core.AsyncResult;
Expand Down Expand Up @@ -82,13 +87,42 @@ public void handle(AsyncResult<AccessToken> event) {
uniEmitter.fail(new AuthenticationFailedException(event.cause()));
return;
}
// Token has been verified, as a JWT or an opaque token, possibly involving
// an introspection request.
final TokenCredential tokenCred = request.getToken();
JsonObject tokenJson = event.result().accessToken();
try {
uniEmitter.complete(
validateAndCreateIdentity(request.getToken(), resolvedContext.oidcConfig,
tokenJson));
} catch (Throwable ex) {
uniEmitter.fail(ex);
if (tokenJson == null) {
// JSON token representation may be null not only if it is an opaque access token
// but also if it is JWT and no JWK with a matching kid is available, asynchronous
// JWK refresh has not finished yet, but the fallback introspection request has succeeded.
tokenJson = OidcUtils.decodeJwtContent(tokenCred.getToken());
}
if (tokenJson != null) {
try {
uniEmitter.complete(
validateAndCreateIdentity(tokenCred, resolvedContext.oidcConfig, tokenJson));
} catch (Throwable ex) {
uniEmitter.fail(ex);
}
} else if (tokenCred instanceof IdTokenCredential
|| tokenCred instanceof AccessTokenCredential
&& !((AccessTokenCredential) tokenCred).isOpaque()) {
uniEmitter
.fail(new AuthenticationFailedException("JWT token can not be converted to JSON"));
} else {
// Opaque access token
QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder();
builder.addCredential(tokenCred);
if (event.result().principal().containsKey("username")) {
final String userName = event.result().principal().getString("username");
builder.setPrincipal(new Principal() {
@Override
public String getName() {
return userName;
}
});
}
uniEmitter.complete(builder.build());
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ private JsonWebToken getTokenCredential(Class<? extends TokenCredential> type) {
}
TokenCredential credential = identity.getCredential(type);
if (credential != null) {
if (credential instanceof AccessTokenCredential && ((AccessTokenCredential) credential).isOpaque()) {
throw new OIDCException("Opaque access token can not be converted to JsonWebToken");
}
JwtClaims jwtClaims;
try {
jwtClaims = new JwtConsumerBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ public void handle(AsyncResult<OAuth2Auth> event) {
}
}
}

auth.missingKeyHandler(new JwkSetRefreshHandler(auth, oidcConfig.token.forcedJwkRefreshInterval));
return new TenantConfigContext(auth, oidcConfig);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package io.quarkus.oidc.runtime;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;
import java.util.regex.Pattern;

import org.eclipse.microprofile.jwt.Claims;
Expand Down Expand Up @@ -32,6 +35,31 @@ private OidcUtils() {

}

public static boolean isOpaqueToken(String token) {
return new StringTokenizer(token, ".").countTokens() != 3;
}

public static JsonObject decodeJwtContent(String jwt) {
StringTokenizer tokens = new StringTokenizer(jwt, ".");
// part 1: skip the token headers
tokens.nextToken();
if (!tokens.hasMoreTokens()) {
return null;
}
// part 2: token content
String encodedContent = tokens.nextToken();

// lets check only 1 more signature part is available
if (tokens.countTokens() != 1) {
return null;
}
try {
return new JsonObject(new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8));
} catch (IllegalArgumentException ex) {
return null;
}
}

public static boolean validateClaims(OidcTenantConfig.Token tokenConfig, JsonObject json) {
if (tokenConfig.issuer.isPresent()) {
String issuer = json.getString(Claims.iss.name());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.quarkus.oidc.runtime;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

Expand All @@ -12,10 +14,14 @@
import java.util.List;
import java.util.stream.Collectors;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

import org.junit.jupiter.api.Test;

import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcTenantConfig;
import io.smallrye.jwt.build.Jwt;
import io.vertx.core.json.JsonObject;

public class OidcUtilsTest {
Expand Down Expand Up @@ -176,6 +182,33 @@ public void testTokenWithCustomRolesWrongPath() throws Exception {
}
}

@Test
public void testTokenIsOpaque() throws Exception {
assertTrue(OidcUtils.isOpaqueToken("123"));
assertTrue(OidcUtils.isOpaqueToken("1.23"));
assertFalse(OidcUtils.isOpaqueToken("1.2.3"));
}

@Test
public void testDecodeOpaqueTokenAsJwt() throws Exception {
assertNull(OidcUtils.decodeJwtContent("123"));
assertNull(OidcUtils.decodeJwtContent("1.23"));
assertNull(OidcUtils.decodeJwtContent("1.2.3"));
}

@Test
public void testDecodeJwt() throws Exception {
final byte[] keyBytes = "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow"
.getBytes(StandardCharsets.UTF_8);
SecretKey key = new SecretKeySpec(keyBytes, 0, keyBytes.length, "HMACSHA256");
String jwt = Jwt.claims().sign(key);
assertNull(OidcUtils.decodeJwtContent(jwt + ".4"));
JsonObject json = OidcUtils.decodeJwtContent(jwt);
assertTrue(json.containsKey("iat"));
assertTrue(json.containsKey("exp"));
assertTrue(json.containsKey("jti"));
}

public static JsonObject read(InputStream input) throws IOException {
try (BufferedReader buffer = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
return new JsonObject(buffer.lines().collect(Collectors.joining("\n")));
Expand Down
5 changes: 5 additions & 0 deletions integration-tests/oidc-tenancy/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
<artifactId>htmlunit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,26 @@
public class CustomTenantConfigResolver implements TenantConfigResolver {
@Override
public OidcTenantConfig resolve(RoutingContext context) {
if ("tenant-d".equals(context.request().path().split("/")[2])) {
String path = context.request().path();
String tenantId = path.split("/")[2];
if ("tenant-d".equals(tenantId)) {
OidcTenantConfig config = new OidcTenantConfig();
config.setTenantId("tenant-id");
config.setTenantId("tenant-d");
config.setAuthServerUrl(getIssuerUrl() + "/realms/quarkus-d");
config.setClientId("quarkus-d");
OidcTenantConfig.Credentials credentials = new OidcTenantConfig.Credentials();

credentials.setSecret("secret");

config.setCredentials(credentials);

OidcTenantConfig.Token token = new OidcTenantConfig.Token();

token.setIssuer(getIssuerUrl() + "/realms/quarkus-d");

config.setToken(token);

config.getCredentials().setSecret("secret");
config.getToken().setIssuer(getIssuerUrl() + "/realms/quarkus-d");
return config;
} else if ("tenant-oidc".equals(tenantId)) {
OidcTenantConfig config = new OidcTenantConfig();
config.setTenantId("tenant-oidc");
String uri = context.request().absoluteURI();
String keycloakUri = path.contains("tenant-opaque")
? uri.replace("/tenant-opaque/tenant-oidc/api/user", "/oidc")
: uri.replace("/tenant/tenant-oidc/api/user", "/oidc");
config.setAuthServerUrl(keycloakUri);
config.setClientId("client");
config.getCredentials().setSecret("secret");
return config;
}
return null;
Expand Down
Loading

0 comments on commit fd413f3

Please sign in to comment.