From 856226615b3aefe8a6445987479758c3af81de2b Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Sat, 27 Jun 2020 16:36:02 +0100 Subject: [PATCH] OIDC UserInfo support --- .../io/quarkus/oidc/OidcTenantConfig.java | 72 ++++++++++++ .../main/java/io/quarkus/oidc/UserInfo.java | 42 +++++++ .../runtime/CodeAuthenticationMechanism.java | 16 ++- .../runtime/DefaultTenantConfigResolver.java | 4 +- .../oidc/runtime/OidcIdentityProvider.java | 106 +++++++++++++++++- .../io/quarkus/oidc/runtime/OidcRecorder.java | 51 +++++---- .../runtime/OidcTokenCredentialProducer.java | 17 +++ .../io/quarkus/oidc/runtime/OidcUtils.java | 16 ++- .../io/quarkus/it/keycloak/OidcResource.java | 1 + .../it/keycloak/TenantOpaqueResource.java | 5 +- .../quarkus/it/keycloak/TenantResource.java | 57 ++++++++-- .../src/main/resources/application.properties | 4 +- .../BearerTokenAuthorizationTest.java | 8 +- .../KeycloakRealmResourceManager.java | 3 + 14 files changed, 350 insertions(+), 52 deletions(-) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfo.java diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index 03a12a22a1332..9cd28a58dc40c 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -459,6 +459,12 @@ public static Roles fromClaimPathAndSeparator(String path, String sep) { @ConfigItem public Optional roleClaimSeparator = Optional.empty(); + /** + * Source of the principal roles. + */ + @ConfigItem + public Optional source = Optional.empty(); + public Optional getRoleClaimPath() { return roleClaimPath; } @@ -474,6 +480,33 @@ public Optional getRoleClaimSeparator() { public void setRoleClaimSeparator(String roleClaimSeparator) { this.roleClaimSeparator = Optional.of(roleClaimSeparator); } + + public Optional getSource() { + return source; + } + + public void setSource(Source source) { + this.source = Optional.of(source); + } + + // Source of the principal roles + public static enum Source { + /** + * ID Token - the default value for the 'web-app' applications. + */ + idtoken, + + /** + * Access Token - the default and only supported value for the 'service' applications; + * can also be used as the source of roles for the 'web-app' applications. + */ + accesstoken, + + /** + * User Info - only supported for the "web-app" applications + */ + userinfo + } } /** @@ -507,6 +540,15 @@ public static class Authentication { @ConfigItem(defaultValue = "true") public boolean removeRedirectParameters = true; + /** + * Both ID and access tokens are verified as part of the authorization code flow and every time + * these tokens are retrieved from the user session. One should disable the access token verification if + * it is only meant to be propagated to the downstream services. + * Note the ID token will always be verified. + */ + @ConfigItem(defaultValue = "true") + public boolean verifyAccessToken = true; + /** * Force 'https' as the 'redirect_uri' parameter scheme when running behind an SSL terminating reverse proxy. * This property, if enabled, will also affect the logout `post_logout_redirect_uri` and the local redirect requests. @@ -533,6 +575,12 @@ public static class Authentication { @ConfigItem public Optional cookiePath = Optional.empty(); + /** + * If this property is set to 'true' then an OIDC UserInfo endpoint will be called + */ + @ConfigItem(defaultValue = "false") + public boolean userInfoRequired; + public Optional getRedirectPath() { return redirectPath; } @@ -580,6 +628,30 @@ public Optional getCookiePath() { public void setCookiePath(String cookiePath) { this.cookiePath = Optional.of(cookiePath); } + + public boolean isUserInfoRequired() { + return userInfoRequired; + } + + public void setUserInfoRequired(boolean userInfoRequired) { + this.userInfoRequired = userInfoRequired; + } + + public boolean isRemoveRedirectParameters() { + return removeRedirectParameters; + } + + public void setRemoveRedirectParameters(boolean removeRedirectParameters) { + this.removeRedirectParameters = removeRedirectParameters; + } + + public boolean isVerifyAccessToken() { + return verifyAccessToken; + } + + public void setVerifyAccessToken(boolean verifyAccessToken) { + this.verifyAccessToken = verifyAccessToken; + } } @ConfigGroup diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfo.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfo.java new file mode 100644 index 0000000000000..4cb8265438a8e --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfo.java @@ -0,0 +1,42 @@ +package io.quarkus.oidc; + +import java.io.StringReader; + +import javax.json.Json; +import javax.json.JsonArray; +import javax.json.JsonObject; +import javax.json.JsonReader; + +public class UserInfo { + + private JsonObject json; + + public UserInfo() { + } + + public UserInfo(String userInfoJson) { + json = toJsonObject(userInfoJson); + } + + public String getString(String name) { + return json.getString(name); + } + + public JsonArray getArray(String name) { + return json.getJsonArray(name); + } + + public JsonObject getObject(String name) { + return json.getJsonObject(name); + } + + public Object get(String name) { + return json.get(name); + } + + private static JsonObject toJsonObject(String userInfoJson) { + try (JsonReader jsonReader = Json.createReader(new StringReader(userInfoJson))) { + return jsonReader.readObject(); + } + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index a37f2392a3b1f..99848c0a7d138 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -94,6 +94,7 @@ public Uni authenticate(RoutingContext context, String refreshToken = tokens[2]; TenantConfigContext configContext = resolver.resolve(context, true); + context.put("access_token", accessToken); return authenticate(identityProviderManager, new IdTokenCredential(idToken, context)) .map(new Function() { @Override @@ -122,8 +123,8 @@ public SecurityIdentity apply(Throwable throwable) { throw new AuthenticationCompletionException(cause); } LOG.debug("Token has expired, trying to refresh it"); - SecurityIdentity identity = trySilentRefresh(configContext, idToken, refreshToken, context, - identityProviderManager); + SecurityIdentity identity = trySilentRefresh(configContext, + refreshToken, context, identityProviderManager); if (identity == null) { LOG.debug("SecurityIdentity is null after a token refresh"); throw new AuthenticationCompletionException(); @@ -276,8 +277,9 @@ public void accept(UniEmitter uniEmitter) { } uniEmitter.fail(new AuthenticationCompletionException(userAsyncResult.cause())); } else { - AccessToken result = AccessToken.class.cast(userAsyncResult.result()); + final AccessToken result = AccessToken.class.cast(userAsyncResult.result()); + context.put("access_token", result.opaqueAccessToken()); authenticate(identityProviderManager, new IdTokenCredential(result.opaqueIdToken(), context)) .subscribe().with(new Consumer() { @Override @@ -287,7 +289,8 @@ public void accept(SecurityIdentity identity) { uniEmitter.fail(new AuthenticationCompletionException()); } processSuccessfulAuthentication(context, configContext, result, identity); - if (configContext.oidcConfig.authentication.removeRedirectParameters + + if (configContext.oidcConfig.authentication.isRemoveRedirectParameters() && context.request().query() != null) { String finalRedirectUri = buildUriWithoutQueryParams(context); if (finalUserQuery != null) { @@ -331,6 +334,7 @@ private String signJwtWithClientSecret(OidcTenantConfig cfg) { private void processSuccessfulAuthentication(RoutingContext context, TenantConfigContext configContext, AccessToken result, SecurityIdentity securityIdentity) { + removeCookie(context, configContext, getSessionCookieName(configContext)); String cookieValue = new StringBuilder(result.opaqueIdToken()) @@ -430,7 +434,7 @@ private boolean isLogout(RoutingContext context, TenantConfigContext configConte return false; } - private SecurityIdentity trySilentRefresh(TenantConfigContext configContext, String idToken, String refreshToken, + private SecurityIdentity trySilentRefresh(TenantConfigContext configContext, String refreshToken, RoutingContext context, IdentityProviderManager identityProviderManager) { Uni cf = Uni.createFrom().emitter(new Consumer>() { @@ -445,6 +449,7 @@ public void accept(UniEmitter emitter) { @Override public void handle(AsyncResult result) { if (result.succeeded()) { + context.put("access_token", token.opaqueAccessToken()); authenticate(identityProviderManager, new IdTokenCredential(token.opaqueIdToken(), context)) .subscribe().with(new Consumer() { @@ -518,5 +523,4 @@ private static String getPostLogoutCookieName(TenantConfigContext configContext) private static String getCookieSuffix(TenantConfigContext configContext) { return !"Default".equals(configContext.oidcConfig.tenantId.get()) ? "_" + configContext.oidcConfig.tenantId.get() : ""; } - } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java index 9b9a71b21c2ee..553ccb283528c 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java @@ -86,7 +86,9 @@ private TenantConfigContext getTenantConfigFromTenantResolver(RoutingContext con boolean isBlocking(RoutingContext context) { TenantConfigContext resolver = resolve(context, false); - return resolver != null && (resolver.auth == null || resolver.oidcConfig.token.refreshExpired); + return resolver != null + && (resolver.auth == null || resolver.oidcConfig.token.refreshExpired + || resolver.oidcConfig.authentication.userInfoRequired); } private TenantConfigContext getTenantConfigFromConfigResolver(RoutingContext context, boolean create) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index 2ce3deebd2c94..dcdac8ea1b7bd 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -11,6 +11,7 @@ import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.IdTokenCredential; +import io.quarkus.oidc.OidcTenantConfig.Roles.Source; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.credential.TokenCredential; import io.quarkus.security.identity.AuthenticationRequestContext; @@ -68,17 +69,24 @@ private Uni authenticate(TokenAuthenticationRequest request, if (resolvedContext.oidcConfig.publicKey.isPresent()) { return validateTokenWithoutOidcServer(request, resolvedContext); } else { - return validateTokenWithOidcServer(request, resolvedContext); + return validateTokenWithOidcServer(vertxContext, request, resolvedContext); } } @SuppressWarnings("deprecation") - private Uni validateTokenWithOidcServer(TokenAuthenticationRequest request, + private Uni validateTokenWithOidcServer(RoutingContext vertxContext, TokenAuthenticationRequest request, TenantConfigContext resolvedContext) { + if (request.getToken() instanceof IdTokenCredential + && (resolvedContext.oidcConfig.authentication.verifyAccessToken + || resolvedContext.oidcConfig.roles.source.get() == Source.accesstoken)) { + vertxContext.put("code_flow_access_token_result", + verifyCodeFlowAccessToken(vertxContext, request, resolvedContext)); + } return Uni.createFrom().emitter(new Consumer>() { @Override public void accept(UniEmitter uniEmitter) { + resolvedContext.auth.decodeToken(request.getToken().getToken(), new Handler>() { @Override @@ -87,20 +95,31 @@ public void handle(AsyncResult 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(); + 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()); } + + JsonObject userInfo = null; + if (resolvedContext.oidcConfig.authentication.isUserInfoRequired()) { + userInfo = getUserInfo(event.result(), (String) vertxContext.get("access_token")); + } if (tokenJson != null) { + JsonObject rolesJson = getRolesJson(vertxContext, resolvedContext, tokenCred, tokenJson, + userInfo); try { uniEmitter.complete( - validateAndCreateIdentity(tokenCred, resolvedContext.oidcConfig, tokenJson)); + validateAndCreateIdentity(vertxContext, tokenCred, resolvedContext.oidcConfig, + tokenJson, rolesJson, userInfo)); } catch (Throwable ex) { uniEmitter.fail(ex); } @@ -110,9 +129,10 @@ public void handle(AsyncResult event) { uniEmitter .fail(new AuthenticationFailedException("JWT token can not be converted to JSON")); } else { - // Opaque access token + // Opaque Bearer Access Token QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); builder.addCredential(tokenCred); + OidcUtils.setSecurityIdentityUserInfo(builder, userInfo); if (event.result().principal().containsKey("username")) { final String userName = event.result().principal().getString("username"); builder.setPrincipal(new Principal() { @@ -122,6 +142,11 @@ public String getName() { } }); } + if (event.result().principal().containsKey("scope")) { + for (String role : event.result().principal().getString("scope").split(" ")) { + builder.addRole(role.trim()); + } + } uniEmitter.complete(builder.build()); } } @@ -130,7 +155,53 @@ public String getName() { }); } - private Uni validateTokenWithoutOidcServer(TokenAuthenticationRequest request, + @SuppressWarnings("deprecation") + private static JsonObject getRolesJson(RoutingContext vertxContext, TenantConfigContext resolvedContext, + TokenCredential tokenCred, + JsonObject tokenJson, JsonObject userInfo) { + JsonObject rolesJson = tokenJson; + if (tokenCred instanceof IdTokenCredential && resolvedContext.oidcConfig.roles.source.isPresent()) { + if (resolvedContext.oidcConfig.roles.source.get() == Source.userinfo) { + rolesJson = userInfo; + } else if (resolvedContext.oidcConfig.roles.source.get() == Source.accesstoken) { + AccessToken result = (AccessToken) vertxContext.get("code_flow_access_token_result"); + rolesJson = result.accessToken(); + if (rolesJson == 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. + rolesJson = OidcUtils.decodeJwtContent((String) vertxContext.get("access_token")); + } + if (rolesJson == null) { + // this is the introspection response which may contain a 'scope' property + rolesJson = result.principal(); + } + } + } + return rolesJson; + } + + @SuppressWarnings("deprecation") + private static AccessToken verifyCodeFlowAccessToken(RoutingContext vertxContext, TokenAuthenticationRequest request, + TenantConfigContext resolvedContext) { + return Uni.createFrom().emitter(new Consumer>() { + @Override + public void accept(UniEmitter uniEmitter) { + resolvedContext.auth.decodeToken((String) vertxContext.get("access_token"), + new Handler>() { + @Override + public void handle(AsyncResult event) { + if (event.failed()) { + uniEmitter.fail(new AuthenticationFailedException(event.cause())); + } + uniEmitter.complete(event.result()); + } + }); + } + }).await().indefinitely(); + } + + private static Uni validateTokenWithoutOidcServer(TokenAuthenticationRequest request, TenantConfigContext resolvedContext) { OAuth2AuthProviderImpl auth = ((OAuth2AuthProviderImpl) resolvedContext.auth); JWT jwt = auth.getJWT(); @@ -145,10 +216,33 @@ private Uni validateTokenWithoutOidcServer(TokenAuthentication } else { try { return Uni.createFrom() - .item(validateAndCreateIdentity(request.getToken(), resolvedContext.oidcConfig, tokenJson)); + .item(validateAndCreateIdentity(null, request.getToken(), resolvedContext.oidcConfig, tokenJson, + tokenJson, + null)); } catch (Throwable ex) { return Uni.createFrom().failure(ex); } } } + + @SuppressWarnings("deprecation") + private static JsonObject getUserInfo(AccessToken tokenImpl, String opaqueAccessToken) { + return Uni.createFrom().emitter( + new Consumer>() { + @Override + public void accept(UniEmitter uniEmitter) { + tokenImpl.principal().put("access_token", opaqueAccessToken); + tokenImpl.userInfo(new Handler>() { + @Override + public void handle(AsyncResult event) { + if (event.failed()) { + uniEmitter.fail(event.cause()); + } else { + uniEmitter.complete(event.result()); + } + } + }); + } + }).await().indefinitely(); + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index 434742d633644..c6a6cacf7e7a3 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -15,6 +15,7 @@ import io.quarkus.oidc.OidcTenantConfig.ApplicationType; import io.quarkus.oidc.OidcTenantConfig.Credentials; import io.quarkus.oidc.OidcTenantConfig.Credentials.Secret; +import io.quarkus.oidc.OidcTenantConfig.Roles.Source; import io.quarkus.oidc.OidcTenantConfig.Tls.Verification; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.runtime.configuration.ConfigurationException; @@ -128,6 +129,24 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi "Use only 'credentials.secret' or 'credentials.client-secret' or 'credentials.jwt.secret' property"); } + if (ApplicationType.SERVICE.equals(oidcConfig.applicationType)) { + if (oidcConfig.token.refreshExpired) { + throw new RuntimeException( + "The 'token.refresh-expired' property can only be enabled for " + ApplicationType.WEB_APP + + " application types"); + } + if (oidcConfig.logout.path.isPresent()) { + throw new RuntimeException( + "The 'logout.path' property can only be enabled for " + ApplicationType.WEB_APP + + " application types"); + } + if (oidcConfig.roles.source.isPresent() && oidcConfig.roles.source.get() != Source.accesstoken) { + throw new RuntimeException( + "The 'roles.source' property can only be set to 'accesstoken' for " + ApplicationType.SERVICE + + " application types"); + } + } + // TODO: The workaround to support client_secret_post is added below and have to be removed once // it is supported again in VertX OAuth2. if (creds.secret.isPresent() || creds.clientSecret.value.isPresent() @@ -174,28 +193,6 @@ public void handle(AsyncResult event) { auth = cf.join(); - if (!ApplicationType.WEB_APP.equals(oidcConfig.applicationType)) { - if (oidcConfig.token.refreshExpired) { - throw new RuntimeException( - "The 'token.refresh-expired' property can only be enabled for " + ApplicationType.WEB_APP - + " application types"); - } - if (oidcConfig.logout.path.isPresent()) { - throw new RuntimeException( - "The 'logout.path' property can only be enabled for " + ApplicationType.WEB_APP - + " application types"); - } - } - - String endSessionEndpoint = OAuth2AuthProviderImpl.class.cast(auth).getConfig().getLogoutPath(); - - if (oidcConfig.logout.path.isPresent()) { - if (!oidcConfig.endSessionPath.isPresent() && endSessionEndpoint == null) { - 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) { @@ -216,6 +213,16 @@ public void handle(AsyncResult event) { } } } + + String endSessionEndpoint = OAuth2AuthProviderImpl.class.cast(auth).getConfig().getLogoutPath(); + + if (oidcConfig.logout.path.isPresent()) { + if (!oidcConfig.endSessionPath.isPresent() && endSessionEndpoint == null) { + throw new RuntimeException( + "The application supports RP-Initiated Logout but the OpenID Provider does not advertise the end_session_endpoint"); + } + } + auth.missingKeyHandler(new JwkSetRefreshHandler(auth, oidcConfig.token.forcedJwkRefreshInterval)); return new TenantConfigContext(auth, oidcConfig); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTokenCredentialProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTokenCredentialProducer.java index cd18a8148e80a..f29e5f85b622f 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTokenCredentialProducer.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTokenCredentialProducer.java @@ -6,7 +6,9 @@ import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.IdTokenCredential; +import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.RefreshToken; +import io.quarkus.oidc.UserInfo; import io.quarkus.security.identity.SecurityIdentity; @RequestScoped @@ -37,4 +39,19 @@ AccessTokenCredential currentAccessToken() { RefreshToken currentRefreshToken() { return identity.getCredential(RefreshToken.class); } + + /** + * The producer method for the current UserInfo + * + * @return the user info + */ + @Produces + @RequestScoped + UserInfo currentUserInfo() { + UserInfo userInfo = (UserInfo) identity.getAttribute("userinfo"); + if (userInfo == null) { + throw new OIDCException("UserInfo can not be injected"); + } + return userInfo; + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index 514dd8c6f03ee..9969954044f0c 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -17,12 +17,14 @@ import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.UserInfo; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.ForbiddenException; import io.quarkus.security.credential.TokenCredential; import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; public final class OidcUtils { /** @@ -150,8 +152,9 @@ private static List convertJsonArrayToList(JsonArray claimValue) { return list; } - static QuarkusSecurityIdentity validateAndCreateIdentity(TokenCredential credential, - OidcTenantConfig config, JsonObject tokenJson) { + static QuarkusSecurityIdentity validateAndCreateIdentity( + RoutingContext vertxContext, TokenCredential credential, + OidcTenantConfig config, JsonObject tokenJson, JsonObject rolesJson, JsonObject userInfo) { try { OidcUtils.validateClaims(config.getToken(), tokenJson); } catch (OIDCException e) { @@ -173,12 +176,19 @@ static QuarkusSecurityIdentity validateAndCreateIdentity(TokenCredential credent builder.setPrincipal(jwtPrincipal); try { String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null; - for (String role : OidcUtils.findRoles(clientId, config.getRoles(), tokenJson)) { + for (String role : findRoles(clientId, config.getRoles(), rolesJson)) { builder.addRole(role); } } catch (Exception e) { throw new ForbiddenException(e); } + setSecurityIdentityUserInfo(builder, userInfo); return builder.build(); } + + public static void setSecurityIdentityUserInfo(QuarkusSecurityIdentity.Builder builder, JsonObject userInfo) { + if (userInfo != null) { + builder.addAttribute("userinfo", new UserInfo(userInfo.encode())); + } + } } diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java index f4f3cc841ce76..34f5d7fed5b06 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java @@ -74,6 +74,7 @@ public String introspect() { // This is done to test that an asynchronous JWK refresh call done by Vertx Auth is effective. return "{" + " \"active\": " + introspection + "," + + " \"scope\": \"user\"," + " \"username\": \"alice\"" + " }"; } diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantOpaqueResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantOpaqueResource.java index 523053328e3f3..21a54be950f13 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantOpaqueResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantOpaqueResource.java @@ -1,9 +1,9 @@ package io.quarkus.it.keycloak; +import javax.annotation.security.RolesAllowed; import javax.inject.Inject; import javax.ws.rs.GET; import javax.ws.rs.Path; -import javax.ws.rs.PathParam; import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.OIDCException; @@ -20,7 +20,8 @@ public class TenantOpaqueResource { AccessTokenCredential accessToken; @GET - public String userName(@PathParam("tenant") String tenant) { + @RolesAllowed("user") + public String userName() { if (!identity.getCredential(AccessTokenCredential.class).isOpaque()) { throw new OIDCException("Opaque token is expected"); } diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java index e1d03fd085344..a6c3f436f2c73 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java @@ -8,9 +8,12 @@ import org.eclipse.microprofile.jwt.JsonWebToken; +import io.quarkus.arc.Arc; import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.IdToken; import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.UserInfo; +import io.quarkus.security.identity.SecurityIdentity; @Path("/tenant/{tenant}/api/user") public class TenantResource { @@ -18,6 +21,9 @@ public class TenantResource { @Inject JsonWebToken accessToken; + @Inject + SecurityIdentity securityIdentity; + @Inject AccessTokenCredential accessTokenCred; @@ -27,18 +33,55 @@ public class TenantResource { @GET @RolesAllowed("user") - public String userName(@PathParam("tenant") String tenant) { - return tenant + ":" + (tenant.startsWith("tenant-web-app") ? getNameWebAppType() : getNameServiceType()); + public String userNameService(@PathParam("tenant") String tenant) { + if (tenant.startsWith("tenant-web-app")) { + throw new OIDCException("Wrong tenant"); + } + return tenant + ":" + getNameServiceType(); + } + + @GET + @Path("webapp") + @RolesAllowed("user") + public String userNameWebApp(@PathParam("tenant") String tenant) { + if (!tenant.equals("tenant-web-app")) { + throw new OIDCException("Wrong tenant"); + } + if (!(securityIdentity.getAttribute("userinfo") instanceof UserInfo)) { + throw new OIDCException("userinfo attribute muset be set"); + } + // Not injecting in the service field as not all tenants require it + UserInfo userInfo = Arc.container().instance(UserInfo.class).get(); + if (!idToken.getGroups().contains("user")) { + throw new OIDCException("Groups expected"); + } + return tenant + ":" + getNameWebAppType(userInfo.getString("upn"), "upn", "preferred_username"); + } + + @GET + @Path("webapp2") + @RolesAllowed("user") + public String userNameWebApp2(@PathParam("tenant") String tenant) { + if (!tenant.equals("tenant-web-app2")) { + throw new OIDCException("Wrong tenant"); + } + if (idToken.getGroups().contains("user")) { + throw new OIDCException("Groups are not expected"); + } + return tenant + ":" + getNameWebAppType(idToken.getName(), "preferred_username", "upn"); } - private String getNameWebAppType() { + private String getNameWebAppType(String name, + String idTokenNameClaim, + String idTokenNameClaimNotExpected) { if (!"ID".equals(idToken.getClaim("typ"))) { throw new OIDCException("Wrong ID token type"); } - String name = idToken.getName(); - // The test is set up to use 'upn' for the 'web-app' application type - if (!name.equals(idToken.getClaim("upn"))) { - throw new OIDCException("upn claim is missing"); + if (!name.equals(idToken.getClaim(idTokenNameClaim))) { + throw new OIDCException(idTokenNameClaim + " claim is missing"); + } + if (idToken.getClaim(idTokenNameClaimNotExpected) != null) { + throw new OIDCException(idTokenNameClaimNotExpected + " claim is not expected"); } // Access token must be available too if (!"Bearer".equals(accessToken.getClaim("typ"))) { diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index e80aeb360d0be..8d1724d1d4939 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -25,15 +25,17 @@ quarkus.oidc.tenant-web-app.auth-server-url=${keycloak.url}/realms/quarkus-webap quarkus.oidc.tenant-web-app.client-id=quarkus-app-webapp quarkus.oidc.tenant-web-app.credentials.secret=secret quarkus.oidc.tenant-web-app.application-type=web-app +quarkus.oidc.tenant-web-app.authentication.user-info-required=true +quarkus.oidc.tenant-web-app.roles.source=userinfo # Tenant Web App2 quarkus.oidc.tenant-web-app2.auth-server-url=${keycloak.url}/realms/quarkus-webapp2 quarkus.oidc.tenant-web-app2.client-id=quarkus-app-webapp2 quarkus.oidc.tenant-web-app2.credentials.secret=secret quarkus.oidc.tenant-web-app2.application-type=web-app +quarkus.oidc.tenant-web-app2.roles.source=accesstoken quarkus.oidc.tenant-public-key.client-id=test quarkus.oidc.tenant-public-key.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEqFyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwRTYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5eUF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYnsIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9xnQIDAQAB smallrye.jwt.sign.key-location=/privateKey.pem - diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index 59819d97a1cc2..3e37c8198d54e 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -38,7 +38,7 @@ public class BearerTokenAuthorizationTest { @Test public void testResolveTenantIdentifierWebApp() throws IOException { try (final WebClient webClient = createWebClient()) { - HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user"); + HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user/webapp"); // State cookie is available but there must be no saved path parameter // as the tenant-web-app configuration does not set a redirect-path property assertNull(getStateCookieSavedPath(webClient, "tenant-web-app")); @@ -55,7 +55,7 @@ public void testResolveTenantIdentifierWebApp() throws IOException { @Test public void testResolveTenantIdentifierWebApp2() throws IOException { try (final WebClient webClient = createWebClient()) { - HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user"); + HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user/webapp2"); // State cookie is available but there must be no saved path parameter // as the tenant-web-app configuration does not set a redirect-path property assertNull(getStateCookieSavedPath(webClient, "tenant-web-app2")); @@ -73,7 +73,7 @@ public void testResolveTenantIdentifierWebApp2() throws IOException { public void testReAuthenticateWhenSwitchingTenants() throws IOException { try (final WebClient webClient = createWebClient()) { // tenant-web-app - HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user"); + HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user/webapp"); assertNull(getStateCookieSavedPath(webClient, "tenant-web-app")); assertEquals("Log in to quarkus-webapp", page.getTitleText()); HtmlForm loginForm = page.getForms().get(0); @@ -82,7 +82,7 @@ public void testReAuthenticateWhenSwitchingTenants() throws IOException { page = loginForm.getInputByName("login").click(); assertEquals("tenant-web-app:alice", page.getBody().asText()); // tenant-web-app2 - page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user"); + page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user/webapp2"); assertNull(getStateCookieSavedPath(webClient, "tenant-web-app2")); assertEquals("Log in to quarkus-webapp2", page.getTitleText()); loginForm = page.getForms().get(0); diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java index 2a1113dcf23eb..bcd0bcf26125f 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java @@ -94,6 +94,9 @@ private static ClientRepresentation createClient(String clientId) { client.setDefaultRoles(new String[] { "role-" + clientId }); if (clientId.startsWith("quarkus-app-webapp")) { client.setRedirectUris(Arrays.asList("*")); + } + if (clientId.equals("quarkus-app-webapp")) { + // This instructs Keycloak to include the roles with the ID token too client.setDefaultClientScopes(Arrays.asList("microprofile-jwt")); } return client;