Skip to content

Commit

Permalink
[fixes #4481] - RP-Initiated Logout and session verification
Browse files Browse the repository at this point in the history
  • Loading branch information
pedroigor committed Apr 9, 2020
1 parent 39a989e commit 94cab89
Show file tree
Hide file tree
Showing 12 changed files with 379 additions and 74 deletions.
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package io.quarkus.oidc.runtime;

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

import java.net.URI;
import java.security.Permission;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.BiFunction;
import java.util.function.Function;

import org.jboss.logging.Logger;
Expand All @@ -19,18 +23,24 @@
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication;
import io.quarkus.oidc.runtime.OidcTenantConfig.Credentials;
import io.quarkus.oidc.runtime.OidcTenantConfig.Credentials.Secret;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.quarkus.vertx.http.runtime.security.AuthenticationCompletionException;
import io.quarkus.vertx.http.runtime.security.AuthenticationRedirectException;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.vertx.core.AsyncResult;
import io.vertx.core.Handler;
import io.vertx.core.http.Cookie;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.impl.ServerCookie;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.oauth2.AccessToken;
import io.vertx.ext.auth.oauth2.impl.OAuth2AuthProviderImpl;
import io.vertx.ext.auth.oauth2.impl.OAuth2TokenImpl;
import io.vertx.ext.jwt.JWT;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.impl.CookieImpl;

Expand All @@ -46,10 +56,11 @@ private static QuarkusSecurityIdentity augmentIdentity(SecurityIdentity security
String accessToken,
String refreshToken,
RoutingContext context) {
final RefreshToken refreshTokenCredential = new RefreshToken(refreshToken);
IdTokenCredential idTokenCredential = securityIdentity.getCredential(IdTokenCredential.class);
RefreshToken refreshTokenCredential = new RefreshToken(refreshToken);
return QuarkusSecurityIdentity.builder()
.setPrincipal(securityIdentity.getPrincipal())
.addCredentials(securityIdentity.getCredentials())
.addCredential(idTokenCredential)
.addCredential(new AccessTokenCredential(accessToken, refreshTokenCredential, context))
.addCredential(refreshTokenCredential)
.addRoles(securityIdentity.getRoles())
Expand All @@ -59,36 +70,75 @@ private static QuarkusSecurityIdentity augmentIdentity(SecurityIdentity security
public CompletionStage<Boolean> apply(Permission permission) {
return securityIdentity.checkPermission(permission);
}
})
.build();
}).build();
}

public CompletionStage<SecurityIdentity> authenticate(RoutingContext context,
IdentityProviderManager identityProviderManager,
DefaultTenantConfigResolver resolver) {
Cookie sessionCookie = context.request().getCookie(SESSION_COOKIE_NAME);
TenantConfigContext configContext = resolver.resolve(context, true);

// if session already established, try to re-authenticate
if (sessionCookie != null) {
String[] tokens = sessionCookie.getValue().split(COOKIE_DELIM);
return authenticate(identityProviderManager, new IdTokenCredential(tokens[0], context))
.thenCompose(new Function<SecurityIdentity, CompletionStage<SecurityIdentity>>() {
String idToken = tokens[0];
String accessToken = tokens[1];
String refreshToken = tokens[2];

return authenticate(identityProviderManager, new IdTokenCredential(idToken, context))
.handle(new BiFunction<SecurityIdentity, Throwable, SecurityIdentity>() {
@Override
public CompletionStage<SecurityIdentity> apply(SecurityIdentity securityIdentity) {
return CompletableFuture
.completedFuture(augmentIdentity(securityIdentity, tokens[1], tokens[2], context));
public SecurityIdentity apply(SecurityIdentity identity, Throwable throwable) {
if (isLogout(context, configContext)) {
// force logout, the challenge will be built accordingly and redirect to the logout endpoint at the OP
throw new AuthenticationFailedException();
}

// if authentication failed, the reason could be the token no longer being valid, so we try a
// silent refresh if required
if (throwable != null) {
if ((identity = trySilentRefresh(configContext, idToken, refreshToken, context)) == null) {
// if the refresh fails, we just propagate the original exception
if (throwable instanceof AuthenticationFailedException) {
throw AuthenticationFailedException.class.cast(throwable);
}
throw new AuthenticationFailedException(throwable);
}
}

return augmentIdentity(identity, accessToken, refreshToken, context);
}
});
}

// start a new session by starting the code flow dance
return performCodeFlow(identityProviderManager, context, resolver);
return performCodeFlow(identityProviderManager, context, configContext);
}

public CompletionStage<ChallengeData> getChallenge(RoutingContext context, DefaultTenantConfigResolver resolver) {
TenantConfigContext configContext = resolver.resolve(context, false);
removeCookie(context, configContext, SESSION_COOKIE_NAME);
ServerCookie sessionCookie = (ServerCookie) context.cookieMap().get(SESSION_COOKIE_NAME);

if (isLogout(context, configContext)) {
String logoutPath = OAuth2AuthProviderImpl.class.cast(configContext.auth).getConfig().getLogoutPath();
String idToken = sessionCookie.getValue().split(COOKIE_DELIM)[0];
StringBuilder logoutUri = new StringBuilder(logoutPath).append("?").append("id_token_hint=").append(idToken);

if (configContext.oidcConfig.logout.redirectUri.isPresent()) {
logoutUri.append("&post_logout_redirect_uri=").append(
buildUri(context, configContext.oidcConfig.logout.redirectUri.get()));
}

// logout locally
removeCookie(context, configContext, SESSION_COOKIE_NAME);

return CompletableFuture.completedFuture(new ChallengeData(HttpResponseStatus.FOUND.code(),
HttpHeaders.LOCATION, logoutUri.toString()));
}

removeCookie(context, configContext, SESSION_COOKIE_NAME);

ChallengeData challenge;
JsonObject params = new JsonObject();

Expand All @@ -101,7 +151,7 @@ public CompletionStage<ChallengeData> getChallenge(RoutingContext context, Defau
// redirect_uri
URI absoluteUri = URI.create(context.request().absoluteURI());
String redirectPath = getRedirectPath(configContext, absoluteUri);
String redirectUriParam = buildRedirectUri(context, absoluteUri, redirectPath);
String redirectUriParam = buildUri(context, redirectPath);
LOG.debugf("Authentication request redirect_uri parameter: %s", redirectUriParam);
params.put("redirect_uri", redirectUriParam);

Expand All @@ -122,9 +172,7 @@ public CompletionStage<ChallengeData> getChallenge(RoutingContext context, Defau
}

private CompletionStage<SecurityIdentity> performCodeFlow(IdentityProviderManager identityProviderManager,
RoutingContext context, DefaultTenantConfigResolver resolver) {
TenantConfigContext configContext = resolver.resolve(context, true);

RoutingContext context, TenantConfigContext configContext) {
JsonObject params = new JsonObject();

String code = context.request().getParam("code");
Expand Down Expand Up @@ -163,7 +211,7 @@ private CompletionStage<SecurityIdentity> performCodeFlow(IdentityProviderManage
extraQuery += ("&" + absoluteUri.getRawQuery());
}

String localRedirectUri = buildRedirectUri(context, absoluteUri, extraPath + extraQuery);
String localRedirectUri = buildUri(context, extraPath + extraQuery);
LOG.debugf("Local redirect URI: %s", localRedirectUri);

cf.completeExceptionally(new AuthenticationRedirectException(localRedirectUri));
Expand All @@ -188,7 +236,7 @@ private CompletionStage<SecurityIdentity> performCodeFlow(IdentityProviderManage

// 'redirect_uri': typically it must match the 'redirect_uri' query parameter which was used during the code request.
String redirectPath = getRedirectPath(configContext, absoluteUri);
String redirectUriParam = buildRedirectUri(context, absoluteUri, redirectPath);
String redirectUriParam = buildUri(context, redirectPath);
LOG.debugf("Token request redirect_uri parameter: %s", redirectUriParam);
params.put("redirect_uri", redirectUriParam);

Expand Down Expand Up @@ -217,7 +265,9 @@ private CompletionStage<SecurityIdentity> performCodeFlow(IdentityProviderManage
LOG.debug("ID Token is requered to contain 'exp' and 'iat' claims");
cf.completeExceptionally(new AuthenticationCompletionException(throwable));
}
processSuccessfulAuthentication(context, configContext, cf, result, securityIdentity);
processSuccessfulAuthentication(context, configContext, result, securityIdentity);
cf.complete(augmentIdentity(securityIdentity, result.opaqueAccessToken(),
result.opaqueRefreshToken(), context));
}
});
}
Expand All @@ -227,7 +277,6 @@ private CompletionStage<SecurityIdentity> performCodeFlow(IdentityProviderManage
}

private void processSuccessfulAuthentication(RoutingContext context, TenantConfigContext configContext,
CompletableFuture<SecurityIdentity> cf,
AccessToken result, SecurityIdentity securityIdentity) {
removeCookie(context, configContext, SESSION_COOKIE_NAME);

Expand All @@ -248,9 +297,6 @@ private void processSuccessfulAuthentication(RoutingContext context, TenantConfi
cookie.setPath(configContext.oidcConfig.authentication.cookiePath.get());
}
context.response().addCookie(cookie);

cf.complete(augmentIdentity(securityIdentity, result.opaqueAccessToken(),
result.opaqueRefreshToken(), context));
}

private String getRedirectPath(TenantConfigContext configContext, URI absoluteUri) {
Expand Down Expand Up @@ -281,14 +327,14 @@ private String generateState(RoutingContext context, TenantConfigContext configC
return uuid;
}

private String buildRedirectUri(RoutingContext context, URI absoluteUri, String path) {
private String buildUri(RoutingContext context, String path) {
return new StringBuilder(context.request().scheme()).append("://")
.append(absoluteUri.getAuthority())
.append(URI.create(context.request().absoluteURI()).getAuthority())
.append(path)
.toString();
}

private void removeCookie(RoutingContext context, TenantConfigContext configContext, String cookieName) {
private ServerCookie removeCookie(RoutingContext context, TenantConfigContext configContext, String cookieName) {
ServerCookie cookie = (ServerCookie) context.cookieMap().get(cookieName);
if (cookie != null) {
cookie.setValue("");
Expand All @@ -298,5 +344,65 @@ private void removeCookie(RoutingContext context, TenantConfigContext configCont
cookie.setPath(auth.cookiePath.get());
}
}
return cookie;
}

private boolean isLogout(RoutingContext context, TenantConfigContext configContext) {
Optional<String> logoutPath = configContext.oidcConfig.logout.path;

if (logoutPath.isPresent()) {
return context.request().absoluteURI().equals(
buildUri(context, logoutPath.get()));
}

return false;
}

private SecurityIdentity trySilentRefresh(TenantConfigContext configContext, String idToken, String refreshToken,
RoutingContext context) {
OidcTenantConfig config = configContext.oidcConfig;

if (config.token.refreshTimeout.isPresent()) {
OAuth2AuthProviderImpl auth = ((OAuth2AuthProviderImpl) configContext.auth);
JWT jwt = auth.getJWT();
JsonObject tokenJson;

try {
tokenJson = jwt.decode(idToken);
} catch (Exception cause) {
throw new AuthenticationFailedException(cause);
}

Long iat = tokenJson.getLong("iat");
long now = System.currentTimeMillis() / 1000;
Integer timeout = config.token.refreshTimeout.get();

if (now - iat >= timeout) {
CompletableFuture<SecurityIdentity> cf = new CompletableFuture<>();
OAuth2TokenImpl token = new OAuth2TokenImpl(configContext.auth, new JsonObject());

token.principal().put("refresh_token", refreshToken);

token.refresh(new Handler<AsyncResult<Void>>() {
@Override
public void handle(AsyncResult<Void> result) {
if (result.succeeded()) {
String rawIdToken = token.principal().getString("id_token");
IdTokenCredential idToken = new IdTokenCredential(rawIdToken, context);
QuarkusSecurityIdentity identity = validateAndCreateIdentity(idToken, config,jwt.decode(idToken.getToken()));
// after a successful refresh, rebuild the identity and update the cookie
processSuccessfulAuthentication(context, configContext, token, identity);
cf.complete(identity);
} else {
cf.completeExceptionally(new AuthenticationFailedException(result.cause()));
}
}
});

return cf.join();
}
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
package io.quarkus.oidc.runtime;

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

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Supplier;

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

import org.eclipse.microprofile.jwt.Claims;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.consumer.InvalidJwtException;

import io.quarkus.oidc.OIDCException;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.ForbiddenException;
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.vertx.core.AsyncResult;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;
Expand Down Expand Up @@ -82,7 +76,8 @@ public void handle(AsyncResult<AccessToken> event) {
}
JsonObject tokenJson = event.result().accessToken();
try {
result.complete(validateAndCreateIdentity(request, resolvedContext.oidcConfig, tokenJson));
result.complete(validateAndCreateIdentity(request.getToken(), resolvedContext.oidcConfig,
tokenJson));
} catch (Throwable ex) {
result.completeExceptionally(ex);
}
Expand All @@ -109,44 +104,11 @@ private CompletableFuture<SecurityIdentity> validateTokenWithoutOidcServer(Token
result.completeExceptionally(new AuthenticationFailedException());
} else {
try {
result.complete(validateAndCreateIdentity(request, resolvedContext.oidcConfig, tokenJson));
result.complete(validateAndCreateIdentity(request.getToken(), resolvedContext.oidcConfig, tokenJson));
} catch (Throwable ex) {
result.completeExceptionally(ex);
}
}
return result;
}

private QuarkusSecurityIdentity validateAndCreateIdentity(TokenAuthenticationRequest request,
OidcTenantConfig config, JsonObject tokenJson)
throws Exception {
try {
OidcUtils.validateClaims(config.getToken(), tokenJson);
} catch (OIDCException e) {
throw new AuthenticationFailedException(e);
}

QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder();
builder.addCredential(request.getToken());

JsonWebToken jwtPrincipal;
try {
JwtClaims jwtClaims = JwtClaims.parse(tokenJson.encode());
jwtClaims.setClaim(Claims.raw_token.name(), request.getToken().getToken());
jwtPrincipal = new OidcJwtCallerPrincipal(jwtClaims, request.getToken(),
config.token.principalClaim.isPresent() ? config.token.principalClaim.get() : null);
} catch (InvalidJwtException e) {
throw new AuthenticationFailedException(e);
}
builder.setPrincipal(jwtPrincipal);
try {
String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null;
for (String role : OidcUtils.findRoles(clientId, config.getRoles(), tokenJson)) {
builder.addRole(role);
}
} catch (Exception e) {
throw new ForbiddenException(e);
}
return builder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ public void handle(AsyncResult<OAuth2Auth> event) {
});

auth = cf.join();

String endSessionEndpoint = OAuth2AuthProviderImpl.class.cast(auth).getConfig().getLogoutPath();

if (endSessionEndpoint == null && oidcConfig.logout.path.isPresent()) {
throw new RuntimeException(
"The application supports RP-Initiated Logout but the OpenID Provider does not advertise the end_session_endpoint");
}

break;
} catch (Throwable throwable) {
while (throwable instanceof CompletionException && throwable.getCause() != null) {
Expand Down
Loading

0 comments on commit 94cab89

Please sign in to comment.