Skip to content

Commit dde59fb

Browse files
committed
Add OIDC CacheControl configuration
1 parent f16dd61 commit dde59fb

File tree

9 files changed

+116
-5
lines changed

9 files changed

+116
-5
lines changed

docs/src/main/asciidoc/security-oidc-expanded-configuration.adoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ Session cookie is created after an authorizaion code flow is completed. An expir
575575
|quarkus.oidc.token.refresh-token-time-skew || Refresh token time skew
576576
|quarkus.oidc.authentication.session-age-extension |5 minutes| Session age extension
577577
|quarkus.oidc.authentication.session-expired-path || Session expired path
578+
|quarkus.oidc.authentication.cache-control || Set of Cache-Control header directives
578579
|====
579580

580581
`quarkus.oidc.token.refresh-expired` can be used to enable automatic user session renewal when the user session has expired and the refresh token is available. This property is not enabled by default because with the automatic renewal, the user, after authenticating once, may not be asked to re-authenticate for a very long time. Therefore an admin level decision may be required to enable an automatic session renewal.
@@ -592,6 +593,9 @@ The `quarkus.oidc.token.refresh-token` property is automatically enabled if the
592593

593594
See also the <<token-state-manager>> section for more information about managing session cookies.
594595

596+
When a new session cookie is created, either after a successful authorization code flow completion or a token refresh, it may be necessary to control how the session cookie is managed by HTTP cache intermediaries and the browser cache.
597+
Currently, only a `Cache-Control` `no-store` directive that prohibits caching the session cookie anywhere in the client request chain can be configured.
598+
595599
[[token-state-manager]]
596600
== Store authorization code flow tokens
597601

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,6 +1819,8 @@ public enum ResponseMode {
18191819
*/
18201820
public Optional<String> stateSecret = Optional.empty();
18211821

1822+
public Optional<Set<CacheControl>> cacheControl = Optional.empty();
1823+
18221824
public Optional<Duration> getInternalIdTokenLifespan() {
18231825
return internalIdTokenLifespan;
18241826
}
@@ -2061,6 +2063,11 @@ public void setSessionExpiredPath(String sessionExpiredPath) {
20612063
this.sessionExpiredPath = Optional.of(sessionExpiredPath);
20622064
}
20632065

2066+
@Override
2067+
public Optional<Set<CacheControl>> cacheControl() {
2068+
return cacheControl;
2069+
}
2070+
20642071
private void addConfigMappingValues(io.quarkus.oidc.runtime.OidcTenantConfig.Authentication mapping) {
20652072
responseMode = mapping.responseMode().map(Enum::toString).map(ResponseMode::valueOf);
20662073
redirectPath = mapping.redirectPath();
@@ -2094,6 +2101,7 @@ private void addConfigMappingValues(io.quarkus.oidc.runtime.OidcTenantConfig.Aut
20942101
pkceRequired = mapping.pkceRequired();
20952102
pkceSecret = mapping.pkceSecret();
20962103
stateSecret = mapping.stateSecret();
2104+
cacheControl = mapping.cacheControl();
20972105
}
20982106
}
20992107

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfigBuilder.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
import java.util.Map;
1010
import java.util.Objects;
1111
import java.util.Optional;
12+
import java.util.Set;
1213

1314
import io.quarkus.oidc.common.runtime.config.OidcClientCommonConfigBuilder;
1415
import io.quarkus.oidc.runtime.OidcConfig;
1516
import io.quarkus.oidc.runtime.OidcTenantConfig;
1617
import io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType;
1718
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication;
19+
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication.CacheControl;
1820
import io.quarkus.oidc.runtime.OidcTenantConfig.CertificateChain;
1921
import io.quarkus.oidc.runtime.OidcTenantConfig.CodeGrant;
2022
import io.quarkus.oidc.runtime.OidcTenantConfig.IntrospectionCredentials;
@@ -234,6 +236,7 @@ public Optional<Provider> provider() {
234236
private IntrospectionCredentials introspectionCredentials;
235237
private Roles roles;
236238
private ResourceMetadata resourceMetadata;
239+
private Optional<Set<CacheControl>> cacheControl;
237240
private CertificateChain certificateChain;
238241
private CodeGrant codeGrant;
239242
private TokenStateManager tokenStateManager;
@@ -858,7 +861,7 @@ public CertificateChain build() {
858861
}
859862

860863
/**
861-
* Builder for the {@link IntrospectionCredentials}.
864+
* Builder for the {@link ResourceMetadata}.
862865
*/
863866
public static final class ResourceMetadataBuilder {
864867

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import java.util.List;
1313
import java.util.Map;
1414
import java.util.Optional;
15+
import java.util.Set;
1516
import java.util.UUID;
1617
import java.util.function.BiFunction;
1718
import java.util.function.Function;
@@ -39,6 +40,7 @@
3940
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
4041
import io.quarkus.oidc.common.runtime.OidcConstants;
4142
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication;
43+
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication.CacheControl;
4244
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication.ResponseMode;
4345
import io.quarkus.oidc.runtime.OidcTenantConfig.Logout.LogoutMode;
4446
import io.quarkus.security.AuthenticationCompletionException;
@@ -1108,6 +1110,16 @@ public Void apply(String cookieValue) {
11081110
OidcUtils.createSessionCookie(context, configContext.oidcConfig(), sessionName,
11091111
cookieValue, sessionMaxAge);
11101112
}
1113+
1114+
Set<CacheControl> cacheControl = configContext.oidcConfig().authentication()
1115+
.cacheControl()
1116+
.orElse(Set.of());
1117+
if (!cacheControl.isEmpty()) {
1118+
// Only 'no-store' is currently supported
1119+
context.response().putHeader(HttpHeaders.CACHE_CONTROL,
1120+
cacheControl.iterator().next().directive());
1121+
}
1122+
11111123
fireEvent(SecurityEvent.Type.OIDC_LOGIN, securityIdentity);
11121124
return null;
11131125
}

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTenantConfig.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,32 @@ enum ResponseMode {
656656
@ConfigDocDefault("query")
657657
Optional<ResponseMode> responseMode();
658658

659+
/**
660+
* Supported cache control directives
661+
*/
662+
enum CacheControl {
663+
NO_STORE("no-store");
664+
665+
private String dir;
666+
667+
CacheControl(String dir) {
668+
this.dir = dir;
669+
}
670+
671+
String directive() {
672+
return dir;
673+
}
674+
}
675+
676+
/**
677+
* Set of cache-control directives that must be set when a new session cookie is created,
678+
* either after a successful authorization code completion or token refresh.
679+
* <p>
680+
* Currently, only a `no-store` directive that prohibits caching the session cookie anywhere in the client request chain
681+
* can be configured.
682+
*/
683+
Optional<Set<CacheControl>> cacheControl();
684+
659685
/**
660686
* The relative path for calculating a `redirect_uri` query parameter.
661687
* It has to start from a forward slash and is appended to the request URI's host and port.

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/builders/AuthenticationConfigBuilder.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
import java.util.ArrayList;
55
import java.util.Arrays;
66
import java.util.HashMap;
7+
import java.util.HashSet;
78
import java.util.List;
89
import java.util.Map;
910
import java.util.Objects;
1011
import java.util.Optional;
12+
import java.util.Set;
1113

1214
import io.quarkus.oidc.OidcTenantConfigBuilder;
1315
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication;
16+
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication.CacheControl;
1417
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication.CookieSameSite;
1518
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication.ResponseMode;
1619

@@ -25,9 +28,9 @@ private record AuthenticationImpl(Optional<ResponseMode> responseMode, Optional<
2528
Optional<List<String>> scopes, Optional<String> scopeSeparator, boolean nonceRequired,
2629
Optional<Boolean> addOpenidScope, Map<String, String> extraParams, Optional<List<String>> forwardParams,
2730
boolean cookieForceSecure, Optional<String> cookieSuffix, String cookiePath, Optional<String> cookiePathHeader,
28-
Optional<String> cookieDomain, CookieSameSite cookieSameSite, boolean allowMultipleCodeFlows,
29-
boolean failOnMissingStateParam, boolean failOnUnresolvedKid, Optional<Boolean> userInfoRequired,
30-
Duration sessionAgeExtension,
31+
Optional<String> cookieDomain, CookieSameSite cookieSameSite, Optional<Set<CacheControl>> cacheControl,
32+
boolean allowMultipleCodeFlows, boolean failOnMissingStateParam, boolean failOnUnresolvedKid,
33+
Optional<Boolean> userInfoRequired, Duration sessionAgeExtension,
3134
Duration stateCookieAge, boolean javaScriptAutoRedirect, Optional<Boolean> idTokenRequired,
3235
Optional<Duration> internalIdTokenLifespan, Optional<Boolean> pkceRequired, Optional<String> pkceSecret,
3336
Optional<String> stateSecret) implements Authentication {
@@ -54,6 +57,7 @@ private record AuthenticationImpl(Optional<ResponseMode> responseMode, Optional<
5457
private Optional<String> cookiePathHeader;
5558
private Optional<String> cookieDomain;
5659
private CookieSameSite cookieSameSite;
60+
private Set<CacheControl> cacheControl = new HashSet<>();
5761
private boolean allowMultipleCodeFlows;
5862
private boolean failOnMissingStateParam;
5963
private boolean failOnUnresolvedKid;
@@ -98,6 +102,9 @@ public AuthenticationConfigBuilder(OidcTenantConfigBuilder builder) {
98102
this.cookiePathHeader = authentication.cookiePathHeader();
99103
this.cookieDomain = authentication.cookieDomain();
100104
this.cookieSameSite = authentication.cookieSameSite();
105+
if (authentication.cacheControl().isPresent()) {
106+
this.cacheControl.addAll(authentication.cacheControl().get());
107+
}
101108
this.allowMultipleCodeFlows = authentication.allowMultipleCodeFlows();
102109
this.failOnMissingStateParam = authentication.failOnMissingStateParam();
103110
this.failOnUnresolvedKid = authentication.failOnUnresolvedKid();
@@ -242,6 +249,15 @@ public AuthenticationConfigBuilder scopes(String... scopes) {
242249
return this;
243250
}
244251

252+
/**
253+
* @param cacheControl {@link Authentication#cacheControl()}
254+
* @return this builder
255+
*/
256+
public AuthenticationConfigBuilder cacheControl(CacheControl directive) {
257+
this.cacheControl.add(directive);
258+
return this;
259+
}
260+
245261
/**
246262
* @param separator {@link Authentication#scopeSeparator()}
247263
* @return this builder
@@ -571,10 +587,13 @@ public Authentication build() {
571587
Optional<List<String>> optionalScopes = scopes.isEmpty() ? Optional.empty() : Optional.of(List.copyOf(scopes));
572588
Optional<List<String>> optionalForwardParams = forwardParams.isEmpty() ? Optional.empty()
573589
: Optional.of(List.copyOf(forwardParams));
590+
Optional<Set<CacheControl>> optionalCacheControl = cacheControl.isEmpty() ? Optional.empty()
591+
: Optional.of(Set.copyOf(cacheControl));
574592
return new AuthenticationImpl(responseMode, redirectPath, restorePathAfterRedirect, removeRedirectParameters, errorPath,
575593
sessionExpiredPath, verifyAccessToken, forceRedirectHttpsScheme, optionalScopes, scopeSeparator, nonceRequired,
576594
addOpenidScope, Map.copyOf(extraParams), optionalForwardParams, cookieForceSecure, cookieSuffix, cookiePath,
577-
cookiePathHeader, cookieDomain, cookieSameSite, allowMultipleCodeFlows, failOnMissingStateParam,
595+
cookiePathHeader, cookieDomain, cookieSameSite, optionalCacheControl, allowMultipleCodeFlows,
596+
failOnMissingStateParam,
578597
failOnUnresolvedKid,
579598
userInfoRequired, sessionAgeExtension, stateCookieAge, javaScriptAutoRedirect, idTokenRequired,
580599
internalIdTokenLifespan, pkceRequired, pkceSecret, stateSecret);

extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ enum ConfigMappingMethods {
142142
AUTHENTICATION_COOKIE_PATH_HEADER,
143143
AUTHENTICATION_COOKIE_DOMAIN,
144144
AUTHENTICATION_COOKIE_SAME_SITE,
145+
AUTHENTICATION_CACHE_CONTROL,
145146
AUTHENTICATION_ALLOW_MULTIPLE_CODE_FLOWS,
146147
AUTHENTICATION_FAIL_ON_MISSING_STATE_PARAM,
147148
AUTHENTICATION_FAIL_ON_UNRESOLVED_KID,
@@ -759,6 +760,12 @@ public CookieSameSite cookieSameSite() {
759760
return CookieSameSite.LAX;
760761
}
761762

763+
@Override
764+
public Optional<Set<CacheControl>> cacheControl() {
765+
invocationsRecorder.put(ConfigMappingMethods.AUTHENTICATION_CACHE_CONTROL, true);
766+
return Optional.empty();
767+
}
768+
762769
@Override
763770
public boolean allowMultipleCodeFlows() {
764771
invocationsRecorder.put(ConfigMappingMethods.AUTHENTICATION_ALLOW_MULTIPLE_CODE_FLOWS, true);

integration-tests/oidc-code-flow/src/main/resources/application.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ quarkus.oidc.tenant-refresh.authentication.cookie-path=/tenant-refresh
9898
quarkus.oidc.tenant-refresh.authentication.session-age-extension=2M
9999
quarkus.oidc.tenant-refresh.authentication.session-expired-path=/tenant-refresh/session-expired-page
100100
quarkus.oidc.tenant-refresh.token.refresh-expired=true
101+
# It avoids an auto-redirect that drops code flow parameters, to make it easier
102+
# to test that Cache-Control is set immediately when the session cookie is created
103+
quarkus.oidc.tenant-refresh.authentication.remove-redirect-parameters=false
104+
quarkus.oidc.tenant-refresh.authentication.cache-control=no-store
101105
quarkus.oidc.tenant-refresh.resource-metadata.enabled=true
102106

103107
quarkus.oidc.tenant-autorefresh.auth-server-url=${quarkus.oidc.auth-server-url}

integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.htmlunit.html.HtmlForm;
3636
import org.htmlunit.html.HtmlPage;
3737
import org.htmlunit.util.Cookie;
38+
import org.htmlunit.util.NameValuePair;
3839
import org.junit.jupiter.api.Assertions;
3940
import org.junit.jupiter.api.Disabled;
4041
import org.junit.jupiter.api.Test;
@@ -829,8 +830,13 @@ public void testTokenRefresh() throws IOException {
829830
page = loginForm.getButtonByName("login").click();
830831
assertEquals("Tenant Refresh, refreshed: false", page.asNormalizedText());
831832

833+
// The session cookie is returned after the authorization code flow completed
834+
// At this point cache-control must be set to `no-store`
835+
assertEquals("no-store", page.getWebResponse().getResponseHeaderValue("cache-control"));
836+
assertNotNull(getSessionSetCookieHeader(page.getWebResponse(), "tenant-refresh"));
832837
Cookie sessionCookie = getSessionCookie(webClient, "tenant-refresh");
833838
assertNotNull(sessionCookie);
839+
834840
String idToken = getIdToken(sessionCookie);
835841

836842
//wait now so that we reach the ID token timeout
@@ -852,11 +858,22 @@ public Boolean call() throws Exception {
852858
}
853859
});
854860

861+
// The session cookie has been refreshed
862+
// At this point cache-control must be set to `no-store`
863+
assertEquals("no-store", page.getWebResponse().getResponseHeaderValue("cache-control"));
864+
assertNotNull(getSessionSetCookieHeader(page.getWebResponse(), "tenant-refresh"));
865+
855866
// local session refreshed and still valid
856867
page = webClient.getPage("http://localhost:8081/tenant-refresh");
857868
assertEquals("Tenant Refresh, refreshed: false", page.asNormalizedText());
869+
870+
// Set-Cookie header is not returned this time
871+
assertNull(getSessionSetCookieHeader(page.getWebResponse(), "tenant-refresh"));
872+
// But WebClient cache has the session cookie
858873
assertNotNull(getSessionCookie(webClient, "tenant-refresh"));
859874

875+
// cache-control is only expected when the session cookie is returned
876+
assertNull(page.getWebResponse().getResponseHeaderValue("cache-control"));
860877
//wait now so that we reach the refresh timeout
861878
await().atMost(20, TimeUnit.SECONDS)
862879
.pollInterval(Duration.ofSeconds(1))
@@ -1782,6 +1799,17 @@ private Cookie getSessionCookie(WebClient webClient, String tenantId) {
17821799
}
17831800
}
17841801

1802+
private String getSessionSetCookieHeader(WebResponse response, String tenantId) {
1803+
String cookieName = "q_session" + (tenantId == null ? "_Default_test" : "_" + tenantId);
1804+
for (NameValuePair h : response.getResponseHeaders()) {
1805+
if ("Set-Cookie".equalsIgnoreCase(h.getName())
1806+
&& h.getValue().startsWith(cookieName)) {
1807+
return h.getValue();
1808+
}
1809+
}
1810+
return null;
1811+
}
1812+
17851813
private Cookie getSessionAtCookie(WebClient webClient, String tenantId) {
17861814
return webClient.getCookieManager().getCookie("q_session_at" + (tenantId == null ? "_Default_test" : "_" + tenantId));
17871815
}

0 commit comments

Comments
 (0)