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 c3697e556c433..aa716dc1a2c37 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -537,6 +537,23 @@ Note that some endpoints do not require the access token. An access token is onl If the ID, access and refresh tokens are JWT tokens then combining all of them (if the strategy is the default `keep-all-tokens`) or only ID and refresh tokens (if the strategy is `id-refresh-token`) may produce a session cookie value larger than 4KB and the browsers may not be able to keep this cookie. In such cases, you can use `quarkus.oidc.token-state-manager.split-tokens=true` to have a unique session token per each of these tokens. +You can also configure the default `TokenStateManager` to encrypt the tokens before storing them as cookie values which may be necessary if the tokens contain sensitive claim values. +For example, here is how you configure it to split the tokens and encrypt them: + +[source, properties] +---- +quarkus.oidc.auth-server-url=http://localhost:8180/auth/realms/quarkus +quarkus.oidc.client-id=quarkus-app +quarkus.oidc.credentials.secret=secret +quarkus.oidc.application-type=web-app +quarkus.oidc.token-state-manager.split-tokens=true +quarkus.oidc.token-state-manager.encryption-required=true +quarkus.oidc.token-state-manager.encryption-secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU +---- + +The tken encryption secret must be 32 characters long. Note that you only have to set `quarkus.oidc.token-state-manager.encryption-secret` if you prefer not to use +`quarkus.oidc.credentials.secret` for encrypting the tokens or if `quarkus.oidc.credentials.secret` length is less than 32 characters. + Register your own `io.quarkus.oidc.TokenStateManager' implementation as an `@ApplicationScoped` CDI bean if you need to customize the way the tokens are associated with the session cookie. For example, you may want to keep the tokens in a database and have only a database pointer stored in a session cookie. Note though that it may present some challenges in making the tokens available across multiple microservices nodes. Here is a simple example: 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 cd05a0c043d7c..dbf32b1374ea3 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 @@ -233,6 +233,36 @@ public enum Strategy { @ConfigItem(defaultValue = "false") public boolean splitTokens; + /** + * Requires that the tokens are encrypted before being stored in the cookies. + */ + @ConfigItem(defaultValueDocumentation = "false") + public Optional encryptionRequired = Optional.empty(); + + /** + * Secret which will be used to encrypt the tokens. + * This secret must be set if the token encryption is required but no client secret is set. + * The length of the secret which will be used to encrypt the tokens must be 32 characters long. + */ + @ConfigItem + public Optional encryptionSecret = Optional.empty(); + + public Optional isEncryptionRequired() { + return encryptionRequired; + } + + public void setEncryptionRequired(boolean encryptionRequired) { + this.encryptionRequired = Optional.of(encryptionRequired); + } + + public Optional getEncryptionSecret() { + return encryptionSecret; + } + + public void setEncryptionSecret(String encryptionSecret) { + this.encryptionSecret = Optional.of(encryptionSecret); + } + public boolean isSplitTokens() { return splitTokens; } 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 ced3d368ada55..f023abc29ea0d 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 @@ -158,6 +158,7 @@ private Uni reAuthenticate(Cookie sessionCookie, IdentityProviderManager identityProviderManager, TenantConfigContext configContext) { + context.put(TenantConfigContext.class.getName(), configContext); return resolver.getTokenStateManager().getTokens(context, configContext.oidcConfig, sessionCookie.getValue(), getTokenStateRequestContext) .chain(new Function>() { @@ -525,6 +526,7 @@ public Uni apply(Void t) { } final long sessionMaxAge = maxAge; context.put(SESSION_MAX_AGE_PARAM, maxAge); + context.put(TenantConfigContext.class.getName(), configContext); return resolver.getTokenStateManager() .createTokenState(context, configContext.oidcConfig, tokens, createTokenStateRequestContext) .map(new Function() { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenStateManager.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenStateManager.java index 13657f406139a..4e51635c34d53 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenStateManager.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenStateManager.java @@ -6,6 +6,7 @@ import io.quarkus.oidc.OidcRequestContext; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.TokenStateManager; +import io.quarkus.security.AuthenticationFailedException; import io.smallrye.mutiny.Uni; import io.vertx.core.http.Cookie; import io.vertx.core.http.impl.ServerCookie; @@ -21,24 +22,24 @@ public class DefaultTokenStateManager implements TokenStateManager { public Uni createTokenState(RoutingContext routingContext, OidcTenantConfig oidcConfig, AuthorizationCodeTokens tokens, OidcRequestContext requestContext) { StringBuilder sb = new StringBuilder(); - sb.append(tokens.getIdToken()); + sb.append(encryptToken(tokens.getIdToken(), routingContext, oidcConfig)); if (oidcConfig.tokenStateManager.strategy == OidcTenantConfig.TokenStateManager.Strategy.KEEP_ALL_TOKENS) { if (!oidcConfig.tokenStateManager.splitTokens) { sb.append(CodeAuthenticationMechanism.COOKIE_DELIM) - .append(tokens.getAccessToken()) + .append(encryptToken(tokens.getAccessToken(), routingContext, oidcConfig)) .append(CodeAuthenticationMechanism.COOKIE_DELIM) - .append(tokens.getRefreshToken()); + .append(encryptToken(tokens.getRefreshToken(), routingContext, oidcConfig)); } else { CodeAuthenticationMechanism.createCookie(routingContext, oidcConfig, getAccessTokenCookieName(oidcConfig), - tokens.getAccessToken(), + encryptToken(tokens.getAccessToken(), routingContext, oidcConfig), routingContext.get(CodeAuthenticationMechanism.SESSION_MAX_AGE_PARAM)); if (tokens.getRefreshToken() != null) { CodeAuthenticationMechanism.createCookie(routingContext, oidcConfig, getRefreshTokenCookieName(oidcConfig), - tokens.getRefreshToken(), + encryptToken(tokens.getRefreshToken(), routingContext, oidcConfig), routingContext.get(CodeAuthenticationMechanism.SESSION_MAX_AGE_PARAM)); } } @@ -47,13 +48,13 @@ public Uni createTokenState(RoutingContext routingContext, OidcTenantCon sb.append(CodeAuthenticationMechanism.COOKIE_DELIM) .append("") .append(CodeAuthenticationMechanism.COOKIE_DELIM) - .append(tokens.getRefreshToken()); + .append(encryptToken(tokens.getRefreshToken(), routingContext, oidcConfig)); } else { if (tokens.getRefreshToken() != null) { CodeAuthenticationMechanism.createCookie(routingContext, oidcConfig, getRefreshTokenCookieName(oidcConfig), - tokens.getRefreshToken(), + encryptToken(tokens.getRefreshToken(), routingContext, oidcConfig), routingContext.get(CodeAuthenticationMechanism.SESSION_MAX_AGE_PARAM)); } } @@ -65,31 +66,31 @@ public Uni createTokenState(RoutingContext routingContext, OidcTenantCon public Uni getTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig, String tokenState, OidcRequestContext requestContext) { String[] tokens = CodeAuthenticationMechanism.COOKIE_PATTERN.split(tokenState); - String idToken = tokens[0]; + String idToken = decryptToken(tokens[0], routingContext, oidcConfig); String accessToken = null; String refreshToken = null; if (oidcConfig.tokenStateManager.strategy == OidcTenantConfig.TokenStateManager.Strategy.KEEP_ALL_TOKENS) { if (!oidcConfig.tokenStateManager.splitTokens) { - accessToken = tokens[1]; - refreshToken = tokens[2]; + accessToken = decryptToken(tokens[1], routingContext, oidcConfig); + refreshToken = decryptToken(tokens[2], routingContext, oidcConfig); } else { Cookie atCookie = getAccessTokenCookie(routingContext, oidcConfig); if (atCookie != null) { - accessToken = atCookie.getValue(); + accessToken = decryptToken(atCookie.getValue(), routingContext, oidcConfig); } Cookie rtCookie = getRefreshTokenCookie(routingContext, oidcConfig); if (rtCookie != null) { - refreshToken = rtCookie.getValue(); + refreshToken = decryptToken(rtCookie.getValue(), routingContext, oidcConfig); } } } else if (oidcConfig.tokenStateManager.strategy == OidcTenantConfig.TokenStateManager.Strategy.ID_REFRESH_TOKENS) { if (!oidcConfig.tokenStateManager.splitTokens) { - refreshToken = tokens[2]; + refreshToken = decryptToken(tokens[2], routingContext, oidcConfig); } else { Cookie rtCookie = getRefreshTokenCookie(routingContext, oidcConfig); if (rtCookie != null) { - refreshToken = rtCookie.getValue(); + refreshToken = decryptToken(rtCookie.getValue(), routingContext, oidcConfig); } } } @@ -126,4 +127,28 @@ private static String getRefreshTokenCookieName(OidcTenantConfig oidcConfig) { String cookieSuffix = CodeAuthenticationMechanism.getCookieSuffix(oidcConfig); return SESSION_RT_COOKIE_NAME + cookieSuffix; } + + private String encryptToken(String token, RoutingContext context, OidcTenantConfig oidcConfig) { + if (oidcConfig.tokenStateManager.encryptionRequired.orElse(false)) { + TenantConfigContext configContext = context.get(TenantConfigContext.class.getName()); + try { + return OidcUtils.encryptString(token, configContext.getTokenEncSecretKey()); + } catch (Exception ex) { + throw new AuthenticationFailedException(ex); + } + } + return token; + } + + private String decryptToken(String token, RoutingContext context, OidcTenantConfig oidcConfig) { + if (oidcConfig.tokenStateManager.encryptionRequired.orElse(false)) { + TenantConfigContext configContext = context.get(TenantConfigContext.class.getName()); + try { + return OidcUtils.decryptString(token, configContext.getTokenEncSecretKey()); + } catch (Exception ex) { + throw new AuthenticationFailedException(ex); + } + } + return token; + } } 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 e018019933129..66bab07869ab2 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 @@ -396,20 +396,28 @@ public static byte[] getSha256Digest(byte[] value) throws NoSuchAlgorithmExcepti } public static String encryptJson(JsonObject json, SecretKey key) throws Exception { + return encryptString(json.encode(), key); + } + + public static String encryptString(String jweString, SecretKey key) throws Exception { JsonWebEncryption jwe = new JsonWebEncryption(); jwe.setAlgorithmHeaderValue(KeyEncryptionAlgorithm.A256KW.getAlgorithm()); jwe.setEncryptionMethodHeaderParameter(ContentEncryptionAlgorithm.A256GCM.getAlgorithm()); jwe.setKey(key); - jwe.setPlaintext(json.encode()); + jwe.setPlaintext(jweString); return jwe.getCompactSerialization(); } public static JsonObject decryptJson(String jweString, SecretKey key) throws Exception { + return new JsonObject(decryptString(jweString, key)); + } + + public static String decryptString(String jweString, SecretKey key) throws Exception { JsonWebEncryption jwe = new JsonWebEncryption(); jwe.setAlgorithmConstraints(new AlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, KeyEncryptionAlgorithm.A256KW.getAlgorithm())); jwe.setKey(key); jwe.setCompactSerialization(jweString); - return new JsonObject(jwe.getPlaintextString()); + return jwe.getPlaintextString(); } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java index e2fe23c97f087..353c5c7b533c4 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java @@ -23,6 +23,11 @@ public class TenantConfigContext { */ private final SecretKey pkceSecretKey; + /** + * Token Encryption Secret Key + */ + private final SecretKey tokenEncSecretKey; + final boolean ready; public TenantConfigContext(OidcProvider client, OidcTenantConfig config) { @@ -35,6 +40,7 @@ public TenantConfigContext(OidcProvider client, OidcTenantConfig config, boolean this.ready = ready; pkceSecretKey = createPkceSecretKey(config); + tokenEncSecretKey = createTokenEncSecretKey(config); } private static SecretKey createPkceSecretKey(OidcTenantConfig config) { @@ -49,6 +55,18 @@ private static SecretKey createPkceSecretKey(OidcTenantConfig config) { return null; } + private static SecretKey createTokenEncSecretKey(OidcTenantConfig config) { + if (config.tokenStateManager.encryptionRequired.orElse(false)) { + String encSecret = config.tokenStateManager.encryptionSecret + .orElse(OidcCommonUtils.clientSecret(config.credentials)); + if (encSecret.length() < 32) { + throw new RuntimeException("Secret key for encrypting tokens must be at least 32 characters long"); + } + return KeyUtils.createSecretKeyFromSecret(encSecret); + } + return null; + } + public OidcTenantConfig getOidcTenantConfig() { return oidcConfig; } @@ -56,4 +74,8 @@ public OidcTenantConfig getOidcTenantConfig() { public SecretKey getPkceSecretKey() { return pkceSecretKey; } + + public SecretKey getTokenEncSecretKey() { + return tokenEncSecretKey; + } } diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java index 3c9fb2b89110d..be0ecbf80e3ab 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java @@ -3,6 +3,7 @@ import java.util.stream.Collectors; import javax.inject.Inject; +import javax.ws.rs.CookieParam; import javax.ws.rs.GET; import javax.ws.rs.InternalServerErrorException; import javax.ws.rs.Path; @@ -111,8 +112,12 @@ public String getNameIdRefreshTokenOnly() { @GET @Path("tenant-split-tokens") - public String getNameSplitTokens() { - return "tenant-split-tokens:" + getName(); + public String getNameSplitTokens(@CookieParam("q_session_tenant-split-tokens") String idToken, + @CookieParam("q_session_at_tenant-split-tokens") String accessToken, + @CookieParam("q_session_rt_tenant-split-tokens") String refreshToken) { + return String.format( + "tenant-split-tokens:%s, id token has %d parts, access token has %d parts, refresh token has %d parts", + getName(), idToken.split("\\.").length, accessToken.split("\\.").length, refreshToken.split("\\.").length); } @GET diff --git a/integration-tests/oidc-code-flow/src/main/resources/application.properties b/integration-tests/oidc-code-flow/src/main/resources/application.properties index ecad5684f425f..c693d6853e3d7 100644 --- a/integration-tests/oidc-code-flow/src/main/resources/application.properties +++ b/integration-tests/oidc-code-flow/src/main/resources/application.properties @@ -135,6 +135,8 @@ quarkus.oidc.tenant-split-tokens.auth-server-url=${keycloak.url}/realms/quarkus quarkus.oidc.tenant-split-tokens.client-id=quarkus-app quarkus.oidc.tenant-split-tokens.credentials.secret=secret quarkus.oidc.tenant-split-tokens.token-state-manager.split-tokens=true +quarkus.oidc.tenant-split-tokens.token-state-manager.encryption-required=true +quarkus.oidc.tenant-split-tokens.token-state-manager.encryption-secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU quarkus.oidc.tenant-split-tokens.application-type=web-app quarkus.http.auth.permission.roles1.paths=/index.html diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java index f35066ba689cc..cdf98cc44460d 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java @@ -866,7 +866,8 @@ public void testDefaultSessionManagerSplitTokens() throws IOException, Interrupt loginForm.getInputByName("password").setValueAttribute("alice"); page = loginForm.getInputByName("login").click(); - assertEquals("tenant-split-tokens:alice", page.getBody().asText()); + assertEquals("tenant-split-tokens:alice, id token has 5 parts, access token has 5 parts, refresh token has 5 parts", + page.getBody().asText()); page = webClient.getPage("http://localhost:8081/web-app/access/tenant-split-tokens"); assertEquals("tenant-split-tokens:AT injected", page.getBody().asText()); @@ -874,13 +875,13 @@ public void testDefaultSessionManagerSplitTokens() throws IOException, Interrupt assertEquals("tenant-split-tokens:RT injected", page.getBody().asText()); Cookie idTokenCookie = getSessionCookie(page.getWebClient(), "tenant-split-tokens"); - checkSingleTokenCookie(idTokenCookie, "ID"); + checkSingleTokenCookie(idTokenCookie, "ID", true); Cookie atTokenCookie = getSessionAtCookie(page.getWebClient(), "tenant-split-tokens"); - checkSingleTokenCookie(atTokenCookie, "Bearer"); + checkSingleTokenCookie(atTokenCookie, "Bearer", true); Cookie rtTokenCookie = getSessionRtCookie(page.getWebClient(), "tenant-split-tokens"); - checkSingleTokenCookie(rtTokenCookie, "Refresh"); + checkSingleTokenCookie(rtTokenCookie, "Refresh", true); // verify all the cookies are cleared after the session timeout webClient.getOptions().setRedirectEnabled(false); @@ -961,10 +962,27 @@ public Boolean call() throws Exception { } } - private void checkSingleTokenCookie(Cookie idTokenCookie, String type) { - String[] parts = idTokenCookie.getValue().split("\\|"); - assertEquals(1, parts.length); - assertEquals(type, OidcUtils.decodeJwtContent(parts[0]).getString("typ")); + private void checkSingleTokenCookie(Cookie tokenCookie, String type) { + checkSingleTokenCookie(tokenCookie, type, false); + + } + + private void checkSingleTokenCookie(Cookie tokenCookie, String type, boolean decrypt) { + String[] cookieParts = tokenCookie.getValue().split("\\|"); + assertEquals(1, cookieParts.length); + String token = cookieParts[0]; + String[] tokenParts = token.split("\\."); + if (decrypt) { + assertEquals(5, tokenParts.length); + try { + token = OidcUtils.decryptString(token, KeyUtils.createSecretKeyFromSecret("eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU")); + tokenParts = token.split("\\."); + } catch (Exception ex) { + fail("Token descryption has failed"); + } + } + assertEquals(3, tokenParts.length); + assertEquals(type, OidcUtils.decodeJwtContent(token).getString("typ")); } @Test