Skip to content

Commit 85d9208

Browse files
authored
Merge pull request #45327 from sberyozkin/oidc_session_cookie_access_token_expiry
Save access token expires_in in the session cookie
2 parents f81fb6c + 2e34eef commit 85d9208

File tree

4 files changed

+83
-13
lines changed

4 files changed

+83
-13
lines changed

extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeTenantReauthenticateTestCase.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import static org.junit.jupiter.api.Assertions.assertEquals;
44
import static org.junit.jupiter.api.Assertions.assertNotNull;
55
import static org.junit.jupiter.api.Assertions.assertNull;
6+
import static org.junit.jupiter.api.Assertions.fail;
7+
8+
import java.util.List;
69

710
import org.htmlunit.SilentCssErrorHandler;
811
import org.htmlunit.TextPage;
@@ -166,7 +169,26 @@ private static WebClient createWebClient() {
166169
return webClient;
167170
}
168171

169-
private static Cookie getSessionCookie(WebClient webClient, String tenantId) {
170-
return webClient.getCookieManager().getCookie("q_session" + (tenantId == null ? "" : "_" + tenantId));
172+
private static List<Cookie> getSessionCookie(WebClient webClient, String tenantId) {
173+
Cookie sessionCookieChunk1 = null;
174+
Cookie sessionCookieChunk2 = null;
175+
176+
String sessionCookieSuffix = "q_session" + (tenantId == null ? "" : "_" + tenantId);
177+
String sessionCookieChunk1Name = sessionCookieSuffix + "_chunk_1";
178+
String sessionCookieChunk2Name = sessionCookieSuffix + "_chunk_2";
179+
for (Cookie c : webClient.getCookieManager().getCookies()) {
180+
if (c.getName().startsWith(sessionCookieSuffix)) {
181+
if (c.getName().equals(sessionCookieChunk1Name)) {
182+
sessionCookieChunk1 = c;
183+
} else if (c.getName().equals(sessionCookieChunk2Name)) {
184+
sessionCookieChunk2 = c;
185+
} else {
186+
fail("Unexpected session cookie chunk: " + c.getName());
187+
}
188+
}
189+
}
190+
return sessionCookieChunk1 != null && sessionCookieChunk2 != null
191+
? List.of(sessionCookieChunk1, sessionCookieChunk2)
192+
: null;
171193
}
172194
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ public Long getAccessTokenExpiresIn() {
9191
/**
9292
* Set the access token expires_in value in seconds.
9393
* It is relative to the time the access token is issued at.
94-
* This property is only checked when an authorization code flow grant completes and does not have to be persisted..
9594
*
9695
* @param accessTokenExpiresIn access token expires_in value in seconds.
9796
*/

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,16 @@ public Uni<String> createTokenState(RoutingContext routingContext, OidcTenantCon
3737
sb.append(CodeAuthenticationMechanism.COOKIE_DELIM)
3838
.append(tokens.getAccessToken())
3939
.append(CodeAuthenticationMechanism.COOKIE_DELIM)
40+
.append(tokens.getAccessTokenExpiresIn() != null ? tokens.getAccessTokenExpiresIn() : "")
41+
.append(CodeAuthenticationMechanism.COOKIE_DELIM)
4042
.append(tokens.getRefreshToken());
4143
} else if (oidcConfig.tokenStateManager().strategy() == Strategy.ID_REFRESH_TOKENS) {
4244
// But sometimes the access token is not required.
4345
// For example, when the Quarkus endpoint does not need to use it to access another service.
44-
// Skip access token, add refresh token
46+
// Skip access token and access token expiry, add refresh token
4547
sb.append(CodeAuthenticationMechanism.COOKIE_DELIM)
48+
.append("")
49+
.append(CodeAuthenticationMechanism.COOKIE_DELIM)
4650
.append("")
4751
.append(CodeAuthenticationMechanism.COOKIE_DELIM)
4852
.append(tokens.getRefreshToken());
@@ -60,11 +64,18 @@ public Uni<String> createTokenState(RoutingContext routingContext, OidcTenantCon
6064
// By default, all three tokens are retained
6165
if (oidcConfig.tokenStateManager().strategy() == Strategy.KEEP_ALL_TOKENS) {
6266

67+
StringBuilder sb = new StringBuilder();
68+
69+
// Add access token and its expires_in property
70+
sb.append(tokens.getAccessToken())
71+
.append(CodeAuthenticationMechanism.COOKIE_DELIM)
72+
.append(tokens.getAccessTokenExpiresIn() != null ? tokens.getAccessTokenExpiresIn() : "");
73+
6374
// Encrypt access token and create a `q_session_at` cookie.
6475
CodeAuthenticationMechanism.createCookie(routingContext,
6576
oidcConfig,
6677
getAccessTokenCookieName(oidcConfig),
67-
encryptToken(tokens.getAccessToken(), routingContext, oidcConfig),
78+
encryptToken(sb.toString(), routingContext, oidcConfig),
6879
routingContext.get(CodeAuthenticationMechanism.SESSION_MAX_AGE_PARAM), true);
6980

7081
// Encrypt refresh token and create a `q_session_rt` cookie.
@@ -97,6 +108,7 @@ public Uni<AuthorizationCodeTokens> getTokens(RoutingContext routingContext, Oid
97108

98109
String idToken = null;
99110
String accessToken = null;
111+
Long accessTokenExpiresIn = null;
100112
String refreshToken = null;
101113

102114
if (!oidcConfig.tokenStateManager().splitTokens()) {
@@ -113,9 +125,10 @@ public Uni<AuthorizationCodeTokens> getTokens(RoutingContext routingContext, Oid
113125

114126
if (oidcConfig.tokenStateManager().strategy() == Strategy.KEEP_ALL_TOKENS) {
115127
accessToken = tokens[1];
116-
refreshToken = tokens[2];
128+
accessTokenExpiresIn = tokens[2].isEmpty() ? null : Long.valueOf(tokens[2]);
129+
refreshToken = tokens[3];
117130
} else if (oidcConfig.tokenStateManager().strategy() == Strategy.ID_REFRESH_TOKENS) {
118-
refreshToken = tokens[2];
131+
refreshToken = tokens[3];
119132
}
120133
} catch (ArrayIndexOutOfBoundsException ex) {
121134
return Uni.createFrom().failure(new AuthenticationCompletionException("Session cookie is malformed"));
@@ -130,7 +143,14 @@ public Uni<AuthorizationCodeTokens> getTokens(RoutingContext routingContext, Oid
130143
Cookie atCookie = getAccessTokenCookie(routingContext, oidcConfig);
131144
if (atCookie != null) {
132145
// Decrypt access token from the q_session_at cookie
133-
accessToken = decryptToken(atCookie.getValue(), routingContext, oidcConfig);
146+
String accessTokenState = decryptToken(atCookie.getValue(), routingContext, oidcConfig);
147+
String[] accessTokenData = CodeAuthenticationMechanism.COOKIE_PATTERN.split(accessTokenState);
148+
accessToken = accessTokenData[0];
149+
try {
150+
accessTokenExpiresIn = accessTokenData[1].isEmpty() ? null : Long.valueOf(accessTokenData[1]);
151+
} catch (ArrayIndexOutOfBoundsException ex) {
152+
return Uni.createFrom().failure(new AuthenticationCompletionException("Session cookie is malformed"));
153+
}
134154
}
135155
Cookie rtCookie = getRefreshTokenCookie(routingContext, oidcConfig);
136156
if (rtCookie != null) {
@@ -144,7 +164,7 @@ public Uni<AuthorizationCodeTokens> getTokens(RoutingContext routingContext, Oid
144164
}
145165
}
146166
}
147-
return Uni.createFrom().item(new AuthorizationCodeTokens(idToken, accessToken, refreshToken));
167+
return Uni.createFrom().item(new AuthorizationCodeTokens(idToken, accessToken, refreshToken, accessTokenExpiresIn));
148168
}
149169

150170
@Override

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,9 +352,15 @@ public void testCodeFlowForceHttpsRedirectUriWithQueryAndPkce() throws Exception
352352
"AES");
353353
String decryptedSessionCookieValue = OidcUtils.decryptString(sessionCookie.getValue(), key);
354354

355-
String encodedIdToken = decryptedSessionCookieValue.split("\\|")[0];
355+
String decrypedSessionCookieValues[] = decryptedSessionCookieValue.split("\\|");
356+
assertEquals(4, decrypedSessionCookieValues.length);
357+
358+
// ID token
359+
String encodedIdToken = decrypedSessionCookieValues[0];
356360

357361
JsonObject idToken = OidcCommonUtils.decodeJwtContent(encodedIdToken);
362+
assertEquals("ID", idToken.getString("typ"));
363+
358364
String expiresAt = idToken.getInteger("exp").toString();
359365
page = webClient.getPage(endpointLocationWithoutQueryUri.toURL());
360366
String response = page.getBody().asNormalizedText();
@@ -363,6 +369,13 @@ public void testCodeFlowForceHttpsRedirectUriWithQueryAndPkce() throws Exception
363369
Integer duration = Integer.valueOf(response.substring(response.length() - 1));
364370
assertTrue(duration > 1 && duration < 5);
365371

372+
// Access token and its expires_in
373+
assertEquals("Bearer", OidcCommonUtils.decodeJwtContent(decrypedSessionCookieValues[1]).getString("typ"));
374+
long atExpiresIn = Long.valueOf(decrypedSessionCookieValues[2]);
375+
assertTrue(atExpiresIn >= 2 && atExpiresIn <= 4);
376+
// Refresh token
377+
assertEquals("Refresh", OidcCommonUtils.decodeJwtContent(decrypedSessionCookieValues[3]).getString("typ"));
378+
366379
assertNull(getSessionCookie(webClient, "tenant-https"));
367380

368381
webClient.getCookieManager().clearCookies();
@@ -1211,10 +1224,13 @@ public void testDefaultSessionManagerIdRefreshTokens() throws Exception {
12111224
String sessionCookieValue = OidcUtils.decryptString(sessionCookie.getValue(), key);
12121225

12131226
String[] parts = sessionCookieValue.split("\\|");
1214-
assertEquals(3, parts.length);
1227+
assertEquals(4, parts.length);
12151228
assertEquals("ID", OidcCommonUtils.decodeJwtContent(parts[0]).getString("typ"));
1229+
// No access token
12161230
assertEquals("", parts[1]);
1217-
assertEquals("Refresh", OidcCommonUtils.decodeJwtContent(parts[2]).getString("typ"));
1231+
// No access token expires_in
1232+
assertEquals("", parts[2]);
1233+
assertEquals("Refresh", OidcCommonUtils.decodeJwtContent(parts[3]).getString("typ"));
12181234

12191235
assertNull(getSessionAtCookie(webClient, "tenant-id-refresh-token"));
12201236
assertNull(getSessionRtCookie(webClient, "tenant-id-refresh-token"));
@@ -1348,7 +1364,20 @@ private void checkSingleTokenCookie(Cookie tokenCookie, String type, String decr
13481364
SecretKey key = new SecretKeySpec(OidcUtils
13491365
.getSha256Digest(decryptSecret.getBytes(StandardCharsets.UTF_8)),
13501366
"AES");
1351-
token = OidcUtils.decryptString(token, key);
1367+
String decryptedString = OidcUtils.decryptString(token, key);
1368+
String[] decryptedStringParts = decryptedString.split("\\|");
1369+
1370+
// If it is an access token then an expiry date should follow the actual token
1371+
if ("Bearer".equals(type)) {
1372+
assertEquals(2, decryptedStringParts.length);
1373+
// Test access token has 3 seconds lifetime
1374+
long atExpiresIn = Long.valueOf(decryptedStringParts[1]);
1375+
assertTrue(atExpiresIn >= 2 && atExpiresIn <= 4);
1376+
} else {
1377+
// For ID and referh tokens it is only a token
1378+
assertEquals(1, decryptedStringParts.length);
1379+
}
1380+
token = decryptedStringParts[0];
13521381
tokenParts = token.split("\\.");
13531382
} catch (Exception ex) {
13541383
fail("Token decryption has failed");

0 commit comments

Comments
 (0)