Skip to content

Commit

Permalink
Merge pull request #23557 from sberyozkin/oidc_encrypt_session_tokens
Browse files Browse the repository at this point in the history
Update OIDC DefaultTokenStateManager to support the token encryption
  • Loading branch information
sberyozkin authored Feb 10, 2022
2 parents 2878a84 + 6a8ccb7 commit 15b26c9
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> 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<String> encryptionSecret = Optional.empty();

public Optional<Boolean> isEncryptionRequired() {
return encryptionRequired;
}

public void setEncryptionRequired(boolean encryptionRequired) {
this.encryptionRequired = Optional.of(encryptionRequired);
}

public Optional<String> getEncryptionSecret() {
return encryptionSecret;
}

public void setEncryptionSecret(String encryptionSecret) {
this.encryptionSecret = Optional.of(encryptionSecret);
}

public boolean isSplitTokens() {
return splitTokens;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ private Uni<SecurityIdentity> 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<AuthorizationCodeTokens, Uni<? extends SecurityIdentity>>() {
Expand Down Expand Up @@ -525,6 +526,7 @@ public Uni<? extends Void> 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<String, Void>() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,24 +22,24 @@ public class DefaultTokenStateManager implements TokenStateManager {
public Uni<String> createTokenState(RoutingContext routingContext, OidcTenantConfig oidcConfig,
AuthorizationCodeTokens tokens, OidcRequestContext<String> 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));
}
}
Expand All @@ -47,13 +48,13 @@ public Uni<String> 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));
}
}
Expand All @@ -65,31 +66,31 @@ public Uni<String> createTokenState(RoutingContext routingContext, OidcTenantCon
public Uni<AuthorizationCodeTokens> getTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig, String tokenState,
OidcRequestContext<AuthorizationCodeTokens> 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);
}
}
}
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -49,11 +55,27 @@ 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;
}

public SecretKey getPkceSecretKey() {
return pkceSecretKey;
}

public SecretKey getTokenEncSecretKey() {
return tokenEncSecretKey;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -866,21 +866,22 @@ 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());
page = webClient.getPage("http://localhost:8081/web-app/refresh/tenant-split-tokens");
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);
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 15b26c9

Please sign in to comment.