Skip to content

Commit a1305b3

Browse files
committed
feat(ws-next,oidc): support identity update before token expires
1 parent 587ee70 commit a1305b3

File tree

19 files changed

+980
-73
lines changed

19 files changed

+980
-73
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
@@ -23,6 +23,7 @@
2323
import java.util.function.BooleanSupplier;
2424
import java.util.function.Predicate;
2525

26+
import io.quarkus.oidc.runtime.WebSocketIdentityUpdateProvider;
2627
import jakarta.enterprise.context.RequestScoped;
2728
import jakarta.inject.Singleton;
2829

@@ -489,6 +490,14 @@ FilterBuildItem registerBackChannelLogoutHandler(BeanContainerBuildItem beanCont
489490
return new FilterBuildItem(handler, FilterBuildItem.AUTHORIZATION - 50);
490491
}
491492

493+
@BuildStep
494+
void supportIdentityUpdateForWebSocketConnections(Capabilities capabilities,
495+
BuildProducer<AdditionalBeanBuildItem> additionalBeanProducer) {
496+
if (capabilities.isPresent(Capability.WEBSOCKETS_NEXT)) {
497+
additionalBeanProducer.produce(AdditionalBeanBuildItem.unremovableOf(WebSocketIdentityUpdateProvider.class));
498+
}
499+
}
500+
492501
private static boolean areEagerSecInterceptorsSupported(Capabilities capabilities,
493502
VertxHttpBuildTimeConfig httpBuildTimeConfig) {
494503
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 & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -188,51 +188,4 @@ public Handler<RoutingContext> getBackChannelLogoutHandler(BeanContainer beanCon
188188
return beanContainer.beanInstance(BackChannelLogoutHandler.class);
189189
}
190190

191-
private static final class TenantSpecificOidcIdentityProvider extends OidcIdentityProvider
192-
implements TenantIdentityProvider {
193-
194-
private final String tenantId;
195-
private final BlockingSecurityExecutor blockingExecutor;
196-
197-
private TenantSpecificOidcIdentityProvider(String tenantId) {
198-
super(Arc.container().instance(DefaultTenantConfigResolver.class).get(),
199-
Arc.container().instance(BlockingSecurityExecutor.class).get());
200-
this.blockingExecutor = Arc.container().instance(BlockingSecurityExecutor.class).get();
201-
this.tenantId = tenantId;
202-
}
203-
204-
@Override
205-
public Uni<SecurityIdentity> authenticate(AccessTokenCredential token) {
206-
return authenticate(new TokenAuthenticationRequest(token));
207-
}
208-
209-
@Override
210-
protected Uni<TenantConfigContext> resolveTenantConfigContext(TokenAuthenticationRequest request,
211-
AuthenticationRequestContext context) {
212-
return tenantResolver.resolveContext(tenantId).onItem().ifNull().failWith(new Supplier<Throwable>() {
213-
@Override
214-
public Throwable get() {
215-
return new OIDCException("Failed to resolve tenant context");
216-
}
217-
});
218-
}
219-
220-
@Override
221-
protected Map<String, Object> getRequestData(TokenAuthenticationRequest request) {
222-
RoutingContext context = getRoutingContextAttribute(request);
223-
if (context != null) {
224-
return context.data();
225-
}
226-
return new HashMap<>();
227-
}
228-
229-
private Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest request) {
230-
return authenticate(request, new AuthenticationRequestContext() {
231-
@Override
232-
public Uni<SecurityIdentity> runBlocking(Supplier<SecurityIdentity> function) {
233-
return blockingExecutor.executeBlocking(function);
234-
}
235-
});
236-
}
237-
}
238191
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package io.quarkus.oidc.runtime;
2+
3+
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;
4+
5+
import java.util.HashMap;
6+
import java.util.Map;
7+
import java.util.function.Supplier;
8+
9+
import io.quarkus.arc.Arc;
10+
import io.quarkus.oidc.AccessTokenCredential;
11+
import io.quarkus.oidc.OIDCException;
12+
import io.quarkus.oidc.TenantIdentityProvider;
13+
import io.quarkus.security.identity.AuthenticationRequestContext;
14+
import io.quarkus.security.identity.SecurityIdentity;
15+
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
16+
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
17+
import io.smallrye.mutiny.Uni;
18+
import io.vertx.ext.web.RoutingContext;
19+
20+
final class TenantSpecificOidcIdentityProvider extends OidcIdentityProvider
21+
implements TenantIdentityProvider {
22+
23+
private final String tenantId;
24+
private final BlockingSecurityExecutor blockingExecutor;
25+
26+
TenantSpecificOidcIdentityProvider(String tenantId, DefaultTenantConfigResolver resolver, BlockingSecurityExecutor blockingExecutor) {
27+
super(resolver, blockingExecutor);
28+
this.blockingExecutor = blockingExecutor;
29+
this.tenantId = tenantId;
30+
}
31+
32+
TenantSpecificOidcIdentityProvider(String tenantId) {
33+
this(tenantId, Arc.container().instance(DefaultTenantConfigResolver.class).get(),
34+
Arc.container().instance(BlockingSecurityExecutor.class).get());
35+
}
36+
37+
@Override
38+
public Uni<SecurityIdentity> authenticate(AccessTokenCredential token) {
39+
return authenticate(new TokenAuthenticationRequest(token));
40+
}
41+
42+
@Override
43+
protected Uni<TenantConfigContext> resolveTenantConfigContext(TokenAuthenticationRequest request,
44+
AuthenticationRequestContext context) {
45+
return tenantResolver.resolveContext(tenantId).onItem().ifNull().failWith(new Supplier<Throwable>() {
46+
@Override
47+
public Throwable get() {
48+
return new OIDCException("Failed to resolve tenant context");
49+
}
50+
});
51+
}
52+
53+
@Override
54+
protected Map<String, Object> getRequestData(TokenAuthenticationRequest request) {
55+
RoutingContext context = getRoutingContextAttribute(request);
56+
if (context != null) {
57+
return context.data();
58+
}
59+
return Map.of();
60+
}
61+
62+
private Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest request) {
63+
return authenticate(request, new AuthenticationRequestContext() {
64+
@Override
65+
public Uni<SecurityIdentity> runBlocking(Supplier<SecurityIdentity> function) {
66+
return blockingExecutor.executeBlocking(function);
67+
}
68+
});
69+
}
70+
}
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)