Skip to content

Commit e951ca3

Browse files
authored
Merge pull request #51220 from sberyozkin/oidc_custom_providers_encrypt_tokens
Encrypt OIDC tokens for custom TokenStateManager implementations
2 parents 7ea0814 + 25423a0 commit e951ca3

File tree

11 files changed

+192
-70
lines changed

11 files changed

+192
-70
lines changed

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -604,14 +604,14 @@ Currently, only a `Cache-Control` `no-store` directive that prohibits caching th
604604
After the authorization code flow is finished, ID token, access token, and refresh token must be retained to support the user session.
605605

606606
By defaut, Quarkus OIDC stores all three tokens in an encrypted session cookie, making Quarkus OIDC stateless.
607-
Quarkus OIDC also provides the stateful xref:security-oidc-code-flow-authentication.adoc#db-token-state-manager[Database TokenStateManager] to store tokens in your database of choice and the xref:security-oidc-code-flow-authentication.adoc#redis-token-state-manager[Redis TokenStateManager] to store them in the Redis cache. Users can also register custom `quarkus.oidc.TokenStateManager` to store these tokens as required.
607+
Quarkus OIDC also provides the stateful xref:security-oidc-code-flow-authentication.adoc#db-token-state-manager[Database TokenStateManager] to store tokens in your database of choice and the xref:security-oidc-code-flow-authentication.adoc#redis-token-state-manager[Redis TokenStateManager] to store them in the Redis cache. Users can also register custom `io.quarkus.oidc.TokenStateManager` to store these tokens as required.
608608

609609
.Default TokenStateManager
610610
[options="header"]
611611
|====
612612
|Property | Default |Description
613613

614-
|quarkus.oidc.token-state-manager.encryption-required |true| Encrypt session cookie by default
614+
|quarkus.oidc.token-state-manager.encryption-required |true| Encrypt session cookie by default, also see the note below
615615
|quarkus.oidc.token-state-manager.encryption-secret || Encryption secret, with falling back to the client secret and finally a generated secret key
616616
|quarkus.oidc.token-state-manager.encryption-algorithm |A256GCMKW| Encryption algorithm
617617
|quarkus.oidc.token-state-manager.split-tokens |false| Cookie per token
@@ -632,6 +632,15 @@ For example, you can do `quarkus.oidc.token-state-manager.strategy=id-refresh-to
632632

633633
If your application does not need to use access tokens but only interact with the authenticated user who must always re-authenticate when the session expires, consider `quarkus.oidc.token-state-manager.strategy=idtoken` - which retains ID token only, ignoring both access and refresh tokens.
634634

635+
[NOTE]
636+
====
637+
`quarkus.oidc.token-state-manager.encryption-required` and two other related properties, `quarkus.oidc.token-state-manager.encryption-secret` and `quarkus.oidc.token-state-manager.encryption-algorithm`, are also effective when custom `io.quarkus.oidc.TokenStateManager` is used, including xref:security-oidc-code-flow-authentication.adoc#db-token-state-manager[Database TokenStateManager] and xref:security-oidc-code-flow-authentication.adoc#redis-token-state-manager[Redis TokenStateManager].
638+
639+
Given that `quarkus.oidc.token-state-manager.encryption-required` is set to `true` by default, a custom `io.quarkus.oidc.TokenStateManager` implementation must encrypt tokens before storing them by default. However, it does not have to encrypt tokens itself, they will be encrypted by the time it is asked to store them.
640+
641+
A custom `io.quarkus.oidc.TokenStateManager` implementation that does not want tokens encrypted by default can disable it with `quarkus.oidc.token-state-manager.encryption-required=false`.
642+
====
643+
635644
[[logout-properties]]
636645
== Logout
637646

@@ -1231,7 +1240,7 @@ You can register a `quarkus.oidc.UserInfoCache` provider to support a custom `Us
12311240
=== TokenStateManager
12321241

12331242
As discussed in the <<token-state-manager>> section above, Quarkus OIDC already provides stateless (default) and stateful options for storing authorization code flow tokens.
1234-
You can also provide your own custom `quarkus.oidc.TokenStateManager` implementation.
1243+
You can also provide your own custom `io.quarkus.oidc.TokenStateManager` implementation.
12351244

12361245
=== Request, response and redirect filters
12371246

extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerProcessor.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ SyntheticBeanBuildItem createDbTokenStateInitializerProps(ReactiveSqlClientBuild
151151
case REACTIVE_DB2_CLIENT:
152152
createTableDdl = "CREATE TABLE oidc_db_token_state_manager ("
153153
+ "id VARCHAR(100) NOT NULL PRIMARY KEY, "
154-
+ "id_token VARCHAR(4000), "
155-
+ "access_token VARCHAR(4000), "
156-
+ "refresh_token VARCHAR(4000), "
154+
+ "id_token VARCHAR(5000), "
155+
+ "access_token VARCHAR(5000), "
156+
+ "refresh_token VARCHAR(5000), "
157157
+ "access_token_expires_in BIGINT, "
158158
+ "access_token_scope VARCHAR(100), "
159159
+ "expires_in BIGINT NOT NULL)";
@@ -162,9 +162,9 @@ SyntheticBeanBuildItem createDbTokenStateInitializerProps(ReactiveSqlClientBuild
162162
case REACTIVE_ORACLE_CLIENT:
163163
createTableDdl = "CREATE TABLE IF NOT EXISTS oidc_db_token_state_manager ("
164164
+ "id VARCHAR2(100), "
165-
+ "id_token VARCHAR2(4000), "
166-
+ "access_token VARCHAR2(4000), "
167-
+ "refresh_token VARCHAR2(4000), "
165+
+ "id_token VARCHAR2(5000), "
166+
+ "access_token VARCHAR2(5000), "
167+
+ "refresh_token VARCHAR2(5000), "
168168
+ "access_token_expires_in NUMBER, "
169169
+ "access_token_scope VARCHAR2(100), "
170170
+ "expires_in NUMBER NOT NULL, "

extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/AbstractDbTokenStateManagerTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public void testCodeFlow() throws IOException {
7575
textPage.getContent());
7676

7777
assertTokenStateCount(1);
78+
assertTokensAreEncryptedInDB();
7879

7980
webClient.getOptions().setRedirectEnabled(false);
8081
WebResponse webResponse = webClient
@@ -97,6 +98,22 @@ protected static void assertTokenStateCount(Integer tokenStateCount) {
9798
.body(Matchers.is(tokenStateCount.toString()));
9899
}
99100

101+
protected void assertTokensAreEncryptedInDB() {
102+
String expectedTokenEncryptionStatus = """
103+
id token encrypted: %1$s, access token encrypted: %1$s, refresh token encrypted: %1$s
104+
""".formatted(tokenEncryptionStatus()).trim();
105+
RestAssured
106+
.given()
107+
.get("public/db-state-manager-tokens")
108+
.then()
109+
.statusCode(200)
110+
.body(Matchers.is(expectedTokenEncryptionStatus));
111+
}
112+
113+
protected boolean tokenEncryptionStatus() {
114+
return true;
115+
}
116+
100117
protected static WebClient createWebClient() {
101118
WebClient webClient = new WebClient();
102119
webClient.setCssErrorHandler(new SilentCssErrorHandler());

extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/HibernateOrmPgDbTokenStateManagerTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ public void testCodeFlowOnTableNotCreatedByExtension() throws IOException {
6868
}
6969
}
7070

71+
@Override
72+
protected boolean tokenEncryptionStatus() {
73+
return false;
74+
}
75+
7176
@Test
7277
public void testExpiredTokenDeletion() {
7378
assertTokenStateCount(0);

extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/PublicResource.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import jakarta.ws.rs.GET;
99
import jakarta.ws.rs.Path;
1010

11+
import io.quarkus.oidc.runtime.OidcUtils;
1112
import io.smallrye.mutiny.Uni;
1213
import io.vertx.sqlclient.Pool;
1314
import io.vertx.sqlclient.Row;
@@ -41,4 +42,30 @@ public Long apply(RowSet<Row> rows) {
4142
.toCompletionStage());
4243
}
4344

45+
@Path("/db-state-manager-tokens")
46+
@GET
47+
public Uni<String> getDbStateManagerTokens() {
48+
return Uni.createFrom().completionStage(pool
49+
.query("SELECT id_token, access_token, refresh_token FROM oidc_db_token_state_manager")
50+
.execute()
51+
.map(new Function<RowSet<Row>, String>() {
52+
@Override
53+
public String apply(RowSet<Row> rows) {
54+
if (rows != null) {
55+
var iterator = rows.iterator();
56+
if (iterator.hasNext()) {
57+
Row row = iterator.next();
58+
return """
59+
id token encrypted: %b, access token encrypted: %b, refresh token encrypted: %b
60+
""".formatted(OidcUtils.isEncryptedToken(row.getString(0)),
61+
OidcUtils.isEncryptedToken(row.getString(1)),
62+
OidcUtils.isEncryptedToken(row.getString(2))).trim();
63+
}
64+
}
65+
return "";
66+
}
67+
})
68+
.toCompletionStage());
69+
}
70+
4471
}

extensions/oidc-db-token-state-manager/deployment/src/test/resources/hibernate-orm-application.properties

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ quarkus.log.category."org.htmlunit.javascript.host.css.CSSStyleSheet".level=FATA
55
quarkus.log.category."org.htmlunit.css".level=FATAL
66
quarkus.oidc.db-token-state-manager.delete-expired-delay=3
77
quarkus.oidc.db-token-state-manager.create-database-table-if-not-exists=false
8+
quarkus.hibernate-orm.log.sql=true
9+
10+
quarkus.oidc.token-state-manager.encryption-required=false

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import io.quarkus.oidc.OidcTenantConfig;
1111
import io.quarkus.oidc.TokenStateManager;
1212
import io.quarkus.oidc.runtime.DefaultTokenStateManager;
13+
import io.quarkus.oidc.runtime.OidcUtils;
1314
import io.smallrye.mutiny.Uni;
1415
import io.vertx.ext.web.RoutingContext;
1516

@@ -24,7 +25,9 @@ public class CustomTokenStateManager implements TokenStateManager {
2425
@Override
2526
public Uni<String> createTokenState(RoutingContext routingContext, OidcTenantConfig oidcConfig,
2627
AuthorizationCodeTokens sessionContent, OidcRequestContext<String> requestContext) {
27-
return tokenStateManager.createTokenState(routingContext, oidcConfig, sessionContent, requestContext)
28+
return tokenStateManager.createTokenState(routingContext, oidcConfig,
29+
OidcUtils.decryptTokens(routingContext, oidcConfig, sessionContent),
30+
requestContext)
2831
.map(t -> (t + "|custom"));
2932
}
3033

@@ -35,7 +38,8 @@ public Uni<AuthorizationCodeTokens> getTokens(RoutingContext routingContext, Oid
3538
throw new IllegalStateException();
3639
}
3740
String defaultState = tokenState.substring(0, tokenState.length() - 7);
38-
return tokenStateManager.getTokens(routingContext, oidcConfig, defaultState, requestContext);
41+
return tokenStateManager.getTokens(routingContext, oidcConfig, defaultState, requestContext)
42+
.map(t -> OidcUtils.encryptTokens(routingContext, oidcConfig, t));
3943
}
4044

4145
@Override

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

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -350,15 +350,16 @@ public Uni<AuthorizationCodeTokens> apply(Throwable t) {
350350
})
351351
.chain(new Function<AuthorizationCodeTokens, Uni<? extends SecurityIdentity>>() {
352352
@Override
353-
public Uni<? extends SecurityIdentity> apply(AuthorizationCodeTokens session) {
354-
context.put(OidcConstants.ACCESS_TOKEN_VALUE, session.getAccessToken());
355-
context.put(AuthorizationCodeTokens.class.getName(), session);
353+
public Uni<? extends SecurityIdentity> apply(AuthorizationCodeTokens tokens) {
354+
AuthorizationCodeTokens decryptedtokens = decryptTokens(context, configContext.oidcConfig(), tokens);
355+
context.put(OidcConstants.ACCESS_TOKEN_VALUE, decryptedtokens.getAccessToken());
356+
context.put(AuthorizationCodeTokens.class.getName(), decryptedtokens);
356357
// Default token state manager may have encrypted ID token when it was saved in a cookie
357-
final String currentIdToken = decryptIdToken(configContext, session.getIdToken());
358+
final String currentIdToken = decryptIdToken(configContext, decryptedtokens.getIdToken());
358359
return authenticate(identityProviderManager, context,
359360
new IdTokenCredential(currentIdToken,
360361
isInternalIdToken(currentIdToken, configContext)))
361-
.call(new LogoutCall(context, configContext, session.getIdToken())).onFailure()
362+
.call(new LogoutCall(context, configContext, decryptedtokens.getIdToken())).onFailure()
362363
.recoverWithUni(new Function<Throwable, Uni<? extends SecurityIdentity>>() {
363364
@Override
364365
public Uni<? extends SecurityIdentity> apply(Throwable t) {
@@ -408,7 +409,7 @@ public Uni<? extends SecurityIdentity> apply(Throwable t) {
408409
if (isRpInitiatedLogout(context, configContext)) {
409410
LOG.debug("Session has expired, performing an RP initiated logout");
410411
fireEvent(SecurityEvent.Type.OIDC_LOGOUT_RP_INITIATED_SESSION_EXPIRED,
411-
Map.of(SecurityEvent.SESSION_TOKENS_PROPERTY, session));
412+
Map.of(SecurityEvent.SESSION_TOKENS_PROPERTY, decryptedtokens));
412413
return Uni.createFrom().item((SecurityIdentity) null)
413414
.call(() -> buildLogoutRedirectUriUni(context, configContext,
414415
currentIdToken));
@@ -418,20 +419,20 @@ public Uni<? extends SecurityIdentity> apply(Throwable t) {
418419
"Token has expired, token refresh is not allowed, redirecting to re-authenticate");
419420
return refreshIsNotPossible(context, configContext, t);
420421
}
421-
if (session.getRefreshToken() == null) {
422+
if (decryptedtokens.getRefreshToken() == null) {
422423
LOG.debug(
423424
"Token has expired, token refresh is not possible because the refresh token is null");
424425
return refreshIsNotPossible(context, configContext, t);
425426
}
426-
if (OidcUtils.isJwtTokenExpired(session.getRefreshToken())) {
427+
if (OidcUtils.isJwtTokenExpired(decryptedtokens.getRefreshToken())) {
427428
LOG.debug(
428429
"Token has expired, token refresh is not possible because the refresh token has expired");
429430
return refreshIsNotPossible(context, configContext, t);
430431
}
431432
LOG.debug("Token has expired, trying to refresh it");
432433
return refreshSecurityIdentity(configContext,
433434
currentIdToken,
434-
session.getRefreshToken(),
435+
decryptedtokens.getRefreshToken(),
435436
context,
436437
identityProviderManager, false, null);
437438
} else {
@@ -441,18 +442,18 @@ public Uni<? extends SecurityIdentity> apply(Throwable t) {
441442
if (isLogout(context, configContext, currentIdentity)) {
442443
// No need to refresh the token since the user is requesting a logout
443444
return Uni.createFrom().item(currentIdentity).call(
444-
new LogoutCall(context, configContext, session.getIdToken()));
445+
new LogoutCall(context, configContext, decryptedtokens.getIdToken()));
445446
}
446447

447448
// Token has nearly expired, try to refresh
448449

449-
if (session.getRefreshToken() == null) {
450+
if (decryptedtokens.getRefreshToken() == null) {
450451
LOG.debug(
451452
"Token auto-refresh is required but is not possible because the refresh token is null");
452453
return autoRefreshIsNotPossible(context, configContext, currentIdentity, t);
453454
}
454455

455-
if (OidcUtils.isJwtTokenExpired(session.getRefreshToken())) {
456+
if (OidcUtils.isJwtTokenExpired(decryptedtokens.getRefreshToken())) {
456457
LOG.debug(
457458
"Token auto-refresh is required but is not possible because the refresh token has expired");
458459
return autoRefreshIsNotPossible(context, configContext, currentIdentity, t);
@@ -461,7 +462,7 @@ public Uni<? extends SecurityIdentity> apply(Throwable t) {
461462
LOG.debug("Token auto-refresh is starting");
462463
return refreshSecurityIdentity(configContext,
463464
currentIdToken,
464-
session.getRefreshToken(),
465+
decryptedtokens.getRefreshToken(),
465466
context,
466467
identityProviderManager, true,
467468
currentIdentity);
@@ -1077,9 +1078,10 @@ public Uni<? extends Void> apply(Void t) {
10771078
context.put(TenantConfigContext.class.getName(), configContext);
10781079
// Just in case, remove the stale Back-Channel Logout data if the previous session was not terminated correctly
10791080
resolver.getBackChannelLogoutTokens().remove(configContext.oidcConfig().tenantId().get());
1080-
1081+
AuthorizationCodeTokens encryptedTokens = encryptTokens(context, configContext.oidcConfig(), tokens);
10811082
return resolver.getTokenStateManager()
1082-
.createTokenState(context, configContext.oidcConfig(), tokens, createTokenStateRequestContext)
1083+
.createTokenState(context, configContext.oidcConfig(), encryptedTokens,
1084+
createTokenStateRequestContext)
10831085
.map(new Function<String, Void>() {
10841086

10851087
@Override
@@ -1131,6 +1133,24 @@ public Void apply(String cookieValue) {
11311133

11321134
}
11331135

1136+
private AuthorizationCodeTokens encryptTokens(RoutingContext context, OidcTenantConfig oidcConfig,
1137+
AuthorizationCodeTokens tokens) {
1138+
if (!(resolver.getTokenStateManager() instanceof DefaultTokenStateManager)
1139+
&& oidcConfig.tokenStateManager().encryptionRequired()) {
1140+
return OidcUtils.encryptTokens(context, oidcConfig, tokens);
1141+
}
1142+
return tokens;
1143+
}
1144+
1145+
private AuthorizationCodeTokens decryptTokens(RoutingContext context, OidcTenantConfig oidcConfig,
1146+
AuthorizationCodeTokens tokens) {
1147+
if (!(resolver.getTokenStateManager() instanceof DefaultTokenStateManager)
1148+
&& oidcConfig.tokenStateManager().encryptionRequired()) {
1149+
return OidcUtils.decryptTokens(context, oidcConfig, tokens);
1150+
}
1151+
return tokens;
1152+
}
1153+
11341154
private void fireEvent(SecurityEvent.Type eventType, SecurityIdentity securityIdentity) {
11351155
if (resolver.isSecurityEventObserved()) {
11361156
SecurityEventHelper.fire(resolver.getSecurityEvent(), new SecurityEvent(eventType, securityIdentity));

0 commit comments

Comments
 (0)