Skip to content

Commit dac2304

Browse files
committed
feat(ws-next,oidc): support identity update before token expires
1 parent 12c75e0 commit dac2304

File tree

19 files changed

+980
-85
lines changed

19 files changed

+980
-85
lines changed

docs/src/main/asciidoc/websockets-next-reference.adoc

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,97 @@ When you plan to use bearer access tokens during the opening WebSocket handshake
10701070
* Use a custom WebSocket ticket system which supplies a random token with the HTML page which hosts the JavaScript WebSockets client which must provide this token during the initial handshake request as a query parameter.
10711071
====
10721072

1073+
Before the bearer access token sent on the initial HTTP request expires, you can send a new bearer access token as part of a message and update current `SecurityIdentity` attached to the WebSocket server connection:
1074+
1075+
[source, java]
1076+
----
1077+
package io.quarkus.websockets.next.test.security;
1078+
1079+
import io.quarkus.security.Authenticated;
1080+
import io.quarkus.security.identity.SecurityIdentity;
1081+
import io.quarkus.websockets.next.OnTextMessage;
1082+
import io.quarkus.websockets.next.WebSocket;
1083+
import io.quarkus.websockets.next.WebSocketSecurity;
1084+
import jakarta.inject.Inject;
1085+
1086+
@Authenticated
1087+
@WebSocket(path = "/end")
1088+
public class Endpoint {
1089+
1090+
record Metadata(String token) {}
1091+
record RequestDto(Metadata metadata, String message) {}
1092+
1093+
@Inject
1094+
SecurityIdentity securityIdentity;
1095+
1096+
@Inject
1097+
WebSocketSecurity webSocketSecurity;
1098+
1099+
@OnTextMessage
1100+
String echo(RequestDto request) {
1101+
if (request.metadata != null && request.metadata.token != null) {
1102+
webSocketSecurity.updateSecurityIdentity(request.metadata.token); <1>
1103+
}
1104+
String principalName = securityIdentity.getPrincipal().getName(); <2>
1105+
return request.message + " " + principalName;
1106+
}
1107+
1108+
}
1109+
----
1110+
<1> Asynchronously update the `SecurityIdentity` attached to the WebSocket server connection.
1111+
<2> The `SecurityIdentity` instance injected into the `Endpoint` will represent the updated identity after Quarkus has finished the asynchronous identity update.
1112+
The update process should be imperceptible, because the `SecurityIdentity` before and after update should only differ in the token expiration time.
1113+
1114+
The xref:security-oidc-bearer-token-authentication.adoc[OIDC Bearer token authentication] mechanism has builtin support for the `SecurityIdentity` update.
1115+
If you use other authentication mechanisms, you must implement the `io.quarkus.security.identity.IdentityProvider` provider that supports the `io.quarkus.websockets.next.runtime.spi.security.WebSocketIdentityUpdateRequest` authentication request.
1116+
1117+
IMPORTANT: Always use the `wss` protocol to enforce encrypted HTTP connection via TLS when sending credentials as part of the WebSocket message.
1118+
1119+
WebSocket client application have to send a new access token before previous one expires:
1120+
1121+
[source,html]
1122+
----
1123+
<script type="module">
1124+
import Keycloak from 'https://cdn.jsdelivr.net/npm/[email protected]/lib/keycloak.js'
1125+
const keycloak = new Keycloak({
1126+
url: 'http://localhost:39245',
1127+
realm: 'quarkus',
1128+
clientId: 'websockets-js-client'
1129+
});
1130+
function getToken() {
1131+
return keycloak.token
1132+
}
1133+
1134+
await keycloak
1135+
.init({onLoad: 'login-required'})
1136+
.then(() => console.log('User is now authenticated.'))
1137+
.catch(err => console.log('User is NOT authenticated.', err));
1138+
1139+
// open Web socket - reduced for brevity
1140+
let connectionOpened = true;
1141+
const subprotocols = [ "quarkus", encodeURI("quarkus-http-upgrade" + "#Authorization#Bearer " + getToken()) ]
1142+
const socket = new WebSocket("wss://" + location.host + "/chat/username", subprotocols);
1143+
1144+
setInterval(() => {
1145+
keycloak
1146+
.updateToken(15)
1147+
.then(result => {
1148+
if (result && connectionOpened) {
1149+
console.log('Token updated, sending new token to the server')
1150+
socket.send(JSON.stringify({
1151+
metadata: {
1152+
token: `${getToken()}`
1153+
}
1154+
}));
1155+
}
1156+
})
1157+
.catch(err => console.error(err))
1158+
}, 10000);
1159+
</script>
1160+
----
1161+
1162+
Complete example is located in the `security-openid-connect-websockets-next-quickstart` link:{quickstarts-tree-url}/security-openid-connect-websockets-next-quickstart[directory].
1163+
10731164
=== Inspect and/or reject HTTP upgrade
10741165

10751166
To inspect an HTTP upgrade, you must provide a CDI bean implementing the `io.quarkus.websockets.next.HttpUpgradeCheck` interface.

extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
import io.quarkus.oidc.runtime.OidcTokenCredentialProducer;
100100
import io.quarkus.oidc.runtime.OidcUtils;
101101
import io.quarkus.oidc.runtime.TenantConfigBean;
102+
import io.quarkus.oidc.runtime.WebSocketIdentityUpdateProvider;
102103
import io.quarkus.oidc.runtime.health.OidcTenantHealthCheck;
103104
import io.quarkus.oidc.runtime.providers.AzureAccessTokenCustomizer;
104105
import io.quarkus.runtime.configuration.ConfigurationException;
@@ -482,6 +483,14 @@ public void registerHealthCheck(OidcBuildTimeConfig config, BuildProducer<Health
482483
}
483484
}
484485

486+
@BuildStep
487+
void supportIdentityUpdateForWebSocketConnections(Capabilities capabilities,
488+
BuildProducer<AdditionalBeanBuildItem> additionalBeanProducer) {
489+
if (capabilities.isPresent(Capability.WEBSOCKETS_NEXT)) {
490+
additionalBeanProducer.produce(AdditionalBeanBuildItem.unremovableOf(WebSocketIdentityUpdateProvider.class));
491+
}
492+
}
493+
485494
private static boolean areEagerSecInterceptorsSupported(Capabilities capabilities,
486495
VertxHttpBuildTimeConfig httpBuildTimeConfig) {
487496
if (httpBuildTimeConfig.auth().proactive()) {

extensions/oidc/runtime/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
<groupId>io.quarkus</groupId>
3838
<artifactId>quarkus-oidc-common</artifactId>
3939
</dependency>
40+
<dependency>
41+
<groupId>io.quarkus</groupId>
42+
<artifactId>quarkus-websockets-next-spi</artifactId>
43+
</dependency>
4044
<dependency>
4145
<groupId>io.smallrye</groupId>
4246
<artifactId>smallrye-jwt</artifactId>

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

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
package io.quarkus.oidc.runtime;
22

33
import static io.quarkus.runtime.configuration.DurationConverter.parseDuration;
4-
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;
54

65
import java.time.Duration;
7-
import java.util.HashMap;
8-
import java.util.Map;
96
import java.util.function.Consumer;
107
import java.util.function.Function;
118
import java.util.function.Supplier;
@@ -18,22 +15,15 @@
1815

1916
import io.quarkus.arc.Arc;
2017
import io.quarkus.arc.SyntheticCreationalContext;
21-
import io.quarkus.oidc.AccessTokenCredential;
22-
import io.quarkus.oidc.OIDCException;
2318
import io.quarkus.oidc.Oidc;
2419
import io.quarkus.oidc.OidcTenantConfig;
2520
import io.quarkus.oidc.TenantIdentityProvider;
2621
import io.quarkus.runtime.annotations.Recorder;
2722
import io.quarkus.runtime.annotations.RuntimeInit;
2823
import io.quarkus.runtime.annotations.StaticInit;
2924
import io.quarkus.security.AuthenticationFailedException;
30-
import io.quarkus.security.identity.AuthenticationRequestContext;
31-
import io.quarkus.security.identity.SecurityIdentity;
32-
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
3325
import io.quarkus.security.runtime.SecurityConfig;
34-
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
3526
import io.quarkus.tls.TlsConfigurationRegistry;
36-
import io.smallrye.mutiny.Uni;
3727
import io.vertx.core.Vertx;
3828
import io.vertx.ext.web.RoutingContext;
3929

@@ -173,52 +163,4 @@ public void accept(RoutingContext routingContext) {
173163
}
174164
};
175165
}
176-
177-
private static final class TenantSpecificOidcIdentityProvider extends OidcIdentityProvider
178-
implements TenantIdentityProvider {
179-
180-
private final String tenantId;
181-
private final BlockingSecurityExecutor blockingExecutor;
182-
183-
private TenantSpecificOidcIdentityProvider(String tenantId) {
184-
super(Arc.container().instance(DefaultTenantConfigResolver.class).get(),
185-
Arc.container().instance(BlockingSecurityExecutor.class).get());
186-
this.blockingExecutor = Arc.container().instance(BlockingSecurityExecutor.class).get();
187-
this.tenantId = tenantId;
188-
}
189-
190-
@Override
191-
public Uni<SecurityIdentity> authenticate(AccessTokenCredential token) {
192-
return authenticate(new TokenAuthenticationRequest(token));
193-
}
194-
195-
@Override
196-
protected Uni<TenantConfigContext> resolveTenantConfigContext(TokenAuthenticationRequest request,
197-
AuthenticationRequestContext context) {
198-
return tenantResolver.resolveContext(tenantId).onItem().ifNull().failWith(new Supplier<Throwable>() {
199-
@Override
200-
public Throwable get() {
201-
return new OIDCException("Failed to resolve tenant context");
202-
}
203-
});
204-
}
205-
206-
@Override
207-
protected Map<String, Object> getRequestData(TokenAuthenticationRequest request) {
208-
RoutingContext context = getRoutingContextAttribute(request);
209-
if (context != null) {
210-
return context.data();
211-
}
212-
return new HashMap<>();
213-
}
214-
215-
private Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest request) {
216-
return authenticate(request, new AuthenticationRequestContext() {
217-
@Override
218-
public Uni<SecurityIdentity> runBlocking(Supplier<SecurityIdentity> function) {
219-
return blockingExecutor.executeBlocking(function);
220-
}
221-
});
222-
}
223-
}
224166
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package io.quarkus.oidc.runtime;
2+
3+
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;
4+
5+
import java.util.Map;
6+
import java.util.function.Supplier;
7+
8+
import io.quarkus.arc.Arc;
9+
import io.quarkus.oidc.AccessTokenCredential;
10+
import io.quarkus.oidc.OIDCException;
11+
import io.quarkus.oidc.TenantIdentityProvider;
12+
import io.quarkus.security.identity.AuthenticationRequestContext;
13+
import io.quarkus.security.identity.SecurityIdentity;
14+
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
15+
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
16+
import io.smallrye.mutiny.Uni;
17+
import io.vertx.ext.web.RoutingContext;
18+
19+
final class TenantSpecificOidcIdentityProvider extends OidcIdentityProvider implements TenantIdentityProvider {
20+
21+
private final String tenantId;
22+
private final BlockingSecurityExecutor blockingExecutor;
23+
24+
TenantSpecificOidcIdentityProvider(String tenantId, DefaultTenantConfigResolver resolver,
25+
BlockingSecurityExecutor blockingExecutor) {
26+
super(resolver, blockingExecutor);
27+
this.blockingExecutor = blockingExecutor;
28+
this.tenantId = tenantId;
29+
}
30+
31+
TenantSpecificOidcIdentityProvider(String tenantId) {
32+
this(tenantId, Arc.container().instance(DefaultTenantConfigResolver.class).get(),
33+
Arc.container().instance(BlockingSecurityExecutor.class).get());
34+
}
35+
36+
@Override
37+
public Uni<SecurityIdentity> authenticate(AccessTokenCredential token) {
38+
return authenticate(new TokenAuthenticationRequest(token));
39+
}
40+
41+
@Override
42+
protected Uni<TenantConfigContext> resolveTenantConfigContext(TokenAuthenticationRequest request,
43+
AuthenticationRequestContext context) {
44+
return tenantResolver.resolveContext(tenantId).onItem().ifNull().failWith(new Supplier<Throwable>() {
45+
@Override
46+
public Throwable get() {
47+
return new OIDCException("Failed to resolve tenant context");
48+
}
49+
});
50+
}
51+
52+
@Override
53+
protected Map<String, Object> getRequestData(TokenAuthenticationRequest request) {
54+
RoutingContext context = getRoutingContextAttribute(request);
55+
if (context != null) {
56+
return context.data();
57+
}
58+
return Map.of();
59+
}
60+
61+
private Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest request) {
62+
return authenticate(request, new AuthenticationRequestContext() {
63+
@Override
64+
public Uni<SecurityIdentity> runBlocking(Supplier<SecurityIdentity> function) {
65+
return blockingExecutor.executeBlocking(function);
66+
}
67+
});
68+
}
69+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package io.quarkus.oidc.runtime;
2+
3+
import static io.quarkus.oidc.common.runtime.OidcConstants.INTROSPECTION_TOKEN_SUB;
4+
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;
5+
6+
import jakarta.enterprise.context.ApplicationScoped;
7+
import jakarta.inject.Inject;
8+
9+
import org.eclipse.microprofile.jwt.JsonWebToken;
10+
11+
import io.quarkus.oidc.AccessTokenCredential;
12+
import io.quarkus.oidc.OidcTenantConfig;
13+
import io.quarkus.oidc.TokenIntrospection;
14+
import io.quarkus.security.AuthenticationFailedException;
15+
import io.quarkus.security.identity.AuthenticationRequestContext;
16+
import io.quarkus.security.identity.IdentityProvider;
17+
import io.quarkus.security.identity.SecurityIdentity;
18+
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
19+
import io.quarkus.websockets.next.runtime.spi.security.WebSocketIdentityUpdateRequest;
20+
import io.smallrye.mutiny.Uni;
21+
import io.vertx.ext.web.RoutingContext;
22+
23+
@ApplicationScoped
24+
public class WebSocketIdentityUpdateProvider implements IdentityProvider<WebSocketIdentityUpdateRequest> {
25+
26+
@Inject
27+
DefaultTenantConfigResolver resolver;
28+
29+
@Inject
30+
BlockingSecurityExecutor blockingExecutor;
31+
32+
WebSocketIdentityUpdateProvider() {
33+
}
34+
35+
@Override
36+
public Class<WebSocketIdentityUpdateRequest> getRequestType() {
37+
return WebSocketIdentityUpdateRequest.class;
38+
}
39+
40+
@Override
41+
public Uni<SecurityIdentity> authenticate(WebSocketIdentityUpdateRequest request,
42+
AuthenticationRequestContext authenticationRequestContext) {
43+
return authenticate(request.getCredential().getToken(), getRoutingContextAttribute(request))
44+
.onItem().transformToUni(newIdentity -> {
45+
if (newIdentity.getPrincipal() instanceof JsonWebToken newJwt
46+
&& request.getCurrentSecurityIdentity().getPrincipal() instanceof JsonWebToken previousJwt) {
47+
String currentSubject = newJwt.getSubject();
48+
String previousSubject = previousJwt.getSubject();
49+
if (currentSubject == null || !currentSubject.equals(previousSubject)) {
50+
return Uni.createFrom().failure(new AuthenticationFailedException(
51+
"JWT token claim 'sub' value '%s' is different to the previous claim value '%s'"
52+
.formatted(currentSubject, previousSubject)));
53+
} else {
54+
return Uni.createFrom().item(newIdentity);
55+
}
56+
}
57+
58+
TokenIntrospection introspection = OidcUtils.getAttribute(newIdentity, OidcUtils.INTROSPECTION_ATTRIBUTE);
59+
if (introspection != null) {
60+
String sub = introspection.getString(INTROSPECTION_TOKEN_SUB);
61+
if (sub != null && !sub.isEmpty()) {
62+
TokenIntrospection previousIntrospection = OidcUtils
63+
.getAttribute(request.getCurrentSecurityIdentity(), OidcUtils.INTROSPECTION_ATTRIBUTE);
64+
if (previousIntrospection == null
65+
|| !sub.equals(previousIntrospection.getString(INTROSPECTION_TOKEN_SUB))) {
66+
return Uni.createFrom().failure(new AuthenticationFailedException(
67+
"Token introspection result claim 'sub' value '%s' is different to the previous claim value '%s'"
68+
.formatted(sub, previousIntrospection == null ? null
69+
: previousIntrospection.getString(INTROSPECTION_TOKEN_SUB))));
70+
} else {
71+
return Uni.createFrom().item(newIdentity);
72+
}
73+
}
74+
}
75+
76+
return Uni.createFrom().failure(new AuthenticationFailedException(
77+
"Cannot verify that updated identity represents same subject as the 'sub' claim is not available"));
78+
});
79+
}
80+
81+
private Uni<SecurityIdentity> authenticate(String accessToken, RoutingContext routingContext) {
82+
final OidcTenantConfig tenantConfig = routingContext.get(OidcTenantConfig.class.getName());
83+
if (tenantConfig == null) {
84+
return Uni.createFrom().failure(new AuthenticationFailedException(
85+
"Cannot update SecurityIdentity because OIDC tenant wasn't resolved for current WebSocket connection"));
86+
}
87+
final var tenantId = tenantConfig.tenantId().get();
88+
final var identityProvider = new TenantSpecificOidcIdentityProvider(tenantId, resolver, blockingExecutor);
89+
final var credential = new AccessTokenCredential(accessToken);
90+
return identityProvider.authenticate(credential);
91+
}
92+
}

0 commit comments

Comments
 (0)