From f1df7dd46a8ff4e03a7978bacce2a489e288ff44 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Wed, 30 Mar 2022 18:25:50 +0100 Subject: [PATCH] Allow to inline UserInfo in internal IdToken --- ...ity-openid-connect-web-authentication.adoc | 6 ++++ .../io/quarkus/oidc/OidcTenantConfig.java | 19 +++++++++++- .../runtime/CodeAuthenticationMechanism.java | 22 ++++++++++---- .../oidc/runtime/OidcIdentityProvider.java | 25 ++++++++++++++-- .../it/keycloak/CodeFlowUserInfoResource.java | 6 ++++ .../CustomSecurityIdentityAugmentor.java | 3 +- .../it/keycloak/CustomTenantResolver.java | 3 ++ .../src/main/resources/application.properties | 9 ++++++ .../keycloak/CodeFlowAuthorizationTest.java | 30 ++++++++++++++++++- 9 files changed, 111 insertions(+), 12 deletions(-) diff --git a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc index 479f936ed7ffe..b46b26c3fcf65 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -522,6 +522,7 @@ Note this user session can not be extended forever - the returning user with the `io.quarkus.oidc.OidcSession` is a wrapper around the current `IdToken`. It can help to perform a <>, retrieve the current session's tenant identifier and check when the session will expire. More useful methods will be added to it over time. +[[token-state-manager]] ==== TokenStateManager OIDC `CodeAuthenticationMechanism` is using the default `io.quarkus.oidc.TokenStateManager` interface implementation to keep the ID, access and refresh tokens returned in the authorization code or refresh grant responses in a session cookie. It makes Quarkus OIDC endpoints completely stateless. @@ -697,6 +698,8 @@ Configuring the endpoint to request <> is the only way `quar Note that requiring <> involves making a remote call on every request - therefore you may want to consider caching `UserInfo` data, see < for more details. +Alternatively, you may want to request that `UserInfo` is embedded into the internal generated `IdToken`with the `quarkus.oidc.cache-user-info-in-idtoken=true` property - the advantage of this approach is that by default no cached `UserInfo` state will be kept with the endpoint - instead it will be stored in a session cookie. You may also want to consider encrypting `IdToken` in this case if `UserInfo` contains sensitive data, please see <> for more information. + Also, OAuth2 servers may not support a well-known configuration endpoint in which case the discovery has to be disabled and the authorization, token, and introspection and/or userinfo endpoint paths have to be configured manually. Here is how you can integrate `quarkus-oidc` with `GitHub` after you have link:https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app[created a GitHub OAuth application]. Configure your Quarkus endpoint like this: @@ -713,6 +716,9 @@ quarkus.oidc.credentials.secret=github_app_clientsecret # Consider enabling UserInfo Cache # quarkus.oidc.token-cache.max-size=1000 # quarkus.oidc.token-cache.time-to-live=5M +# +# Or having UserInfo cached inside IdToken itself +# quarkus.oidc.cache-user-info-in-idtoken=true ---- This is all what is needed for an endpoint like this one to return the currently authenticated user's profile with `GET http://localhost:8080/github/userinfo` and access it as the individual `UserInfo` properties: 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 5cba0871e5de0..7a0112c1d1d66 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 @@ -132,6 +132,15 @@ public class OidcTenantConfig extends OidcCommonConfig { @ConfigItem(defaultValue = "true") public boolean allowUserInfoCache = true; + /** + * Allow inlining UserInfo in IdToken instead of caching it in the token cache. + * This property is only checked when an internal IdToken is generated when Oauth2 providers do not return IdToken. + * Inlining UserInfo in the generated IdToken allows to store it in the session cookie and avoids introducing a cached + * state. + */ + @ConfigItem(defaultValue = "false") + public boolean cacheUserInfoInIdtoken = false; + @ConfigGroup public static class Logout { @@ -637,7 +646,7 @@ public enum ResponseMode { /** * Requires that ID token is available when the authorization code flow completes. * Disable this property only when you need to use the authorization code flow with OAuth2 providers which do not return - * ID token. + * ID token - an internal IdToken will be generated in such cases. */ @ConfigItem(defaultValueDocumentation = "true") public Optional idTokenRequired = Optional.empty(); @@ -1090,4 +1099,12 @@ public boolean isAllowUserInfoCache() { public void setAllowUserInfoCache(boolean allowUserInfoCache) { this.allowUserInfoCache = allowUserInfoCache; } + + public boolean isCacheUserInfoInIdtoken() { + return cacheUserInfoInIdtoken; + } + + public void setCacheUserInfoInIdtoken(boolean cacheUserInfoInIdtoken) { + this.cacheUserInfoInIdtoken = cacheUserInfoInIdtoken; + } } 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 a956a6e8c6553..9be8d0b93cde6 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 @@ -30,6 +30,7 @@ import io.quarkus.oidc.OidcTenantConfig.Authentication; import io.quarkus.oidc.OidcTenantConfig.Authentication.ResponseMode; import io.quarkus.oidc.SecurityEvent; +import io.quarkus.oidc.UserInfo; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.security.AuthenticationCompletionException; @@ -39,6 +40,7 @@ import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.vertx.http.runtime.security.ChallengeData; import io.smallrye.jwt.build.Jwt; +import io.smallrye.jwt.build.JwtClaimsBuilder; import io.smallrye.jwt.util.KeyUtils; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.subscription.UniEmitter; @@ -467,14 +469,13 @@ public Uni apply(final AuthorizationCodeTokens tokens, final T return Uni.createFrom().failure(new AuthenticationCompletionException(tOuter)); } - boolean internalIdToken = false; + boolean internalIdToken = !configContext.oidcConfig.authentication.isIdTokenRequired().orElse(true); if (tokens.getIdToken() == null) { - if (configContext.oidcConfig.authentication.isIdTokenRequired().orElse(true)) { + if (!internalIdToken) { return Uni.createFrom() .failure(new AuthenticationCompletionException("ID Token is not available")); } else { - tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig)); - internalIdToken = true; + tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig, null)); } } @@ -487,6 +488,11 @@ public Uni apply(final AuthorizationCodeTokens tokens, final T .call(new Function>() { @Override public Uni apply(SecurityIdentity identity) { + if (internalIdToken && configContext.oidcConfig.allowUserInfoCache + && configContext.oidcConfig.cacheUserInfoInIdtoken) { + tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig, + identity.getAttribute(OidcUtils.USER_INFO_ATTRIBUTE))); + } return processSuccessfulAuthentication(context, configContext, tokens, identity); } @@ -561,8 +567,12 @@ private CodeAuthenticationStateBean getCodeAuthenticationBean(String[] parsedSta return null; } - private String generateInternalIdToken(OidcTenantConfig oidcConfig) { - return Jwt.claims().jws().header(INTERNAL_IDTOKEN_HEADER, true) + private String generateInternalIdToken(OidcTenantConfig oidcConfig, UserInfo userInfo) { + JwtClaimsBuilder builder = Jwt.claims(); + if (userInfo != null) { + builder.claim(OidcUtils.USER_INFO_ATTRIBUTE, userInfo.getJsonObject()); + } + return builder.jws().header(INTERNAL_IDTOKEN_HEADER, true) .sign(KeyUtils.createSecretKeyFromSecret(OidcCommonUtils.clientSecret(oidcConfig.credentials))); } 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 216b997ce31aa..81861252179a4 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 @@ -134,8 +134,14 @@ public Uni apply(UserInfo userInfo, Throwable t) { private Uni createSecurityIdentityWithOidcServer(RoutingContext vertxContext, TokenAuthenticationRequest request, TenantConfigContext resolvedContext, final UserInfo userInfo) { Uni tokenUni = null; - if ((request.getToken() instanceof IdTokenCredential) && ((IdTokenCredential) request.getToken()).isInternal()) { - tokenUni = verifySelfSignedTokenUni(resolvedContext, request.getToken().getToken()); + if (isInternalIdToken(request)) { + if (vertxContext.get(NEW_AUTHENTICATION) == Boolean.TRUE) { + // No need to verify it in this case as 'CodeAuthenticationMechanism' has just created it + tokenUni = Uni.createFrom() + .item(new TokenVerificationResult(OidcUtils.decodeJwtContent(request.getToken().getToken()), null)); + } else { + tokenUni = verifySelfSignedTokenUni(resolvedContext, request.getToken().getToken()); + } } else { tokenUni = verifyTokenUni(resolvedContext, request.getToken().getToken()); } @@ -218,6 +224,10 @@ public String getName() { }); } + private static boolean isInternalIdToken(TokenAuthenticationRequest request) { + return (request.getToken() instanceof IdTokenCredential) && ((IdTokenCredential) request.getToken()).isInternal(); + } + private static boolean tokenAutoRefreshPrepared(JsonObject tokenJson, RoutingContext vertxContext, OidcTenantConfig oidcConfig) { if (tokenJson != null @@ -350,6 +360,14 @@ private static Uni validateTokenWithoutOidcServer(TokenAuthent private Uni getUserInfoUni(RoutingContext vertxContext, TokenAuthenticationRequest request, TenantConfigContext resolvedContext) { + if (isInternalIdToken(request) && resolvedContext.oidcConfig.cacheUserInfoInIdtoken) { + JsonObject userInfo = OidcUtils.decodeJwtContent(request.getToken().getToken()) + .getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE); + if (userInfo != null) { + return Uni.createFrom().item(new UserInfo(userInfo.encode())); + } + } + String accessToken = vertxContext.get(OidcConstants.ACCESS_TOKEN_VALUE); if (accessToken == null) { accessToken = request.getToken().getToken(); @@ -369,7 +387,8 @@ private Uni getUserInfoUni(RoutingContext vertxContext, TokenAuthentic private Uni newUserInfoUni(TenantConfigContext resolvedContext, String accessToken) { Uni userInfoUni = resolvedContext.provider.getUserInfo(accessToken); - if (tenantResolver.getUserInfoCache() == null || !resolvedContext.oidcConfig.allowUserInfoCache) { + if (tenantResolver.getUserInfoCache() == null || !resolvedContext.oidcConfig.allowUserInfoCache + || resolvedContext.oidcConfig.cacheUserInfoInIdtoken) { return userInfoUni; } else { return userInfoUni.call(new Function>() { diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java index dc53433a87a0b..1353ab349ff36 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java @@ -37,6 +37,12 @@ public String accessGitHub() { return access(); } + @GET + @Path("/code-flow-user-info-github-cached-in-idtoken") + public String accessGitHubCachedInIdToken() { + return access(); + } + @GET @Path("/code-flow-user-info-dynamic-github") public String accessDynamicGitHub() { diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java index c0682efe28f22..5271cc2bdccae 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomSecurityIdentityAugmentor.java @@ -21,7 +21,8 @@ public Uni augment(SecurityIdentity identity, AuthenticationRe if (routingContext != null && (routingContext.normalizedPath().endsWith("code-flow-user-info-only") || routingContext.normalizedPath().endsWith("code-flow-user-info-github") - || routingContext.normalizedPath().endsWith("code-flow-user-info-dynamic-github"))) { + || routingContext.normalizedPath().endsWith("code-flow-user-info-dynamic-github") + || routingContext.normalizedPath().endsWith("code-flow-user-info-github-cached-in-idtoken"))) { QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); UserInfo userInfo = identity.getAttribute("userinfo"); builder.setPrincipal(new Principal() { diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index 7dc67117b3073..531152a42ede4 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -26,6 +26,9 @@ public String resolve(RoutingContext context) { if (path.endsWith("code-flow-user-info-github")) { return "code-flow-user-info-github"; } + if (path.endsWith("code-flow-user-info-github-cached-in-idtoken")) { + return "code-flow-user-info-github-cached-in-idtoken"; + } if (path.endsWith("bearer")) { return "bearer"; } diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index 4d45ef85b2438..bb2a955b7362b 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -51,6 +51,15 @@ quarkus.oidc.code-flow-user-info-github.user-info-path=protocol/openid-connect/u quarkus.oidc.code-flow-user-info-github.client-id=quarkus-web-app quarkus.oidc.code-flow-user-info-github.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.provider=github +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.auth-server-url=${keycloak.url}/realms/quarkus/ +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.authorization-path=/ +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.user-info-path=protocol/openid-connect/userinfo +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.cache-user-info-in-idtoken=true +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.client-id=quarkus-web-app +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow + + quarkus.oidc.token-cache.max-size=1 quarkus.oidc.bearer.auth-server-url=${keycloak.url}/realms/quarkus/ diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index 3d73aedc14bd8..1d12e7ef372eb 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -21,10 +21,12 @@ import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; +import io.quarkus.oidc.runtime.OidcUtils; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.oidc.server.OidcWireMock; import io.quarkus.test.oidc.server.OidcWiremockTestResource; +import io.vertx.core.json.JsonObject; @QuarkusTest @QuarkusTestResource(OidcWiremockTestResource.class) @@ -82,6 +84,8 @@ public void testCodeFlowUserInfo() throws IOException { doTestCodeFlowUserInfo("code-flow-user-info-only"); doTestCodeFlowUserInfo("code-flow-user-info-github"); doTestCodeFlowUserInfo("code-flow-user-info-dynamic-github"); + + doTestCodeFlowUserInfoCashedInIdToken(); } private void doTestCodeFlowUserInfo(String tenantId) throws IOException { @@ -97,7 +101,31 @@ private void doTestCodeFlowUserInfo(String tenantId) throws IOException { assertEquals("alice:alice, cache size: 1", page.getBody().asText()); - assertNotNull(getSessionCookie(webClient, tenantId)); + Cookie sessionCookie = getSessionCookie(webClient, tenantId); + assertNotNull(sessionCookie); + JsonObject idTokenClaims = OidcUtils.decodeJwtContent(sessionCookie.getValue().split("\\|")[0]); + assertNull(idTokenClaims.getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE)); + webClient.getCookieManager().clearCookies(); + } + } + + private void doTestCodeFlowUserInfoCashedInIdToken() throws IOException { + try (final WebClient webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(true); + HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken"); + + HtmlForm form = page.getFormByName("form"); + form.getInputByName("username").type("alice"); + form.getInputByName("password").type("alice"); + + page = form.getInputByValue("login").click(); + + assertEquals("alice:alice, cache size: 0", page.getBody().asText()); + + Cookie sessionCookie = getSessionCookie(webClient, "code-flow-user-info-github-cached-in-idtoken"); + assertNotNull(sessionCookie); + JsonObject idTokenClaims = OidcUtils.decodeJwtContent(sessionCookie.getValue().split("\\|")[0]); + assertNotNull(idTokenClaims.getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE)); webClient.getCookieManager().clearCookies(); } }