Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/src/main/asciidoc/websockets-next-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,101 @@ When you plan to use bearer access tokens during the opening WebSocket handshake
* 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.
====

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:

[source, java]
----
package io.quarkus.websockets.next.test.security;

import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.websockets.next.OnTextMessage;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketSecurity;
import jakarta.inject.Inject;

@Authenticated
@WebSocket(path = "/end")
public class Endpoint {

record Metadata(String token) {}
record RequestDto(Metadata metadata, String message) {}

@Inject
SecurityIdentity securityIdentity;

@Inject
WebSocketSecurity webSocketSecurity;

@OnTextMessage
String echo(RequestDto request) {
if (request.metadata != null && request.metadata.token != null) {
webSocketSecurity.updateSecurityIdentity(request.metadata.token); <1>
}
String principalName = securityIdentity.getPrincipal().getName(); <2>
return request.message + " " + principalName;
}

}
----
<1> Asynchronously update the `SecurityIdentity` attached to the WebSocket server connection.
<2> The current `SecurityIdentity` instance is still available and can be used during the `SecurityIdentity` update.

The xref:security-oidc-bearer-token-authentication.adoc[OIDC Bearer token authentication] mechanism has builtin support for the `SecurityIdentity` update.
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.

[IMPORTANT]
====
The new bearer access token must have the same `sub` claim value as the token used during the initial HTTP request.
Please also make sure the `SecurityIdentity` is only updated when necessary and the WebSocket message with credentials do not appear in your application logs.
Always use the `wss` protocol to enforce encrypted HTTP connection via TLS when sending credentials as part of the WebSocket message.
====

WebSocket client application have to send a new access token before previous one expires:

[source,html]
----
<script type="module">
import Keycloak from 'https://cdn.jsdelivr.net/npm/[email protected]/lib/keycloak.js'
const keycloak = new Keycloak({
url: 'http://localhost:39245',
realm: 'quarkus',
clientId: 'websockets-js-client'
});
function getToken() {
return keycloak.token
}

await keycloak
.init({onLoad: 'login-required'})
.then(() => console.log('User is now authenticated.'))
.catch(err => console.log('User is NOT authenticated.', err));

// open Web socket - reduced for brevity
let connectionOpened = true;
const subprotocols = [ "quarkus", encodeURI("quarkus-http-upgrade" + "#Authorization#Bearer " + getToken()) ]
const socket = new WebSocket("wss://" + location.host + "/chat/username", subprotocols);

setInterval(() => {
keycloak
.updateToken(15)
.then(result => {
if (result && connectionOpened) {
console.log('Token updated, sending new token to the server')
socket.send(JSON.stringify({
metadata: {
token: `${getToken()}`
}
}));
}
})
.catch(err => console.error(err))
}, 10000);
</script>
----

Complete example is located in the `security-openid-connect-websockets-next-quickstart` link:{quickstarts-tree-url}/security-openid-connect-websockets-next-quickstart[directory].

=== Inspect and/or reject HTTP upgrade

To inspect an HTTP upgrade, you must provide a CDI bean implementing the `io.quarkus.websockets.next.HttpUpgradeCheck` interface.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
import io.quarkus.oidc.runtime.OidcTokenCredentialProducer;
import io.quarkus.oidc.runtime.OidcUtils;
import io.quarkus.oidc.runtime.TenantConfigBean;
import io.quarkus.oidc.runtime.WebSocketIdentityUpdateProvider;
import io.quarkus.oidc.runtime.health.OidcTenantHealthCheck;
import io.quarkus.oidc.runtime.providers.AzureAccessTokenCustomizer;
import io.quarkus.runtime.configuration.ConfigurationException;
Expand Down Expand Up @@ -489,6 +490,14 @@ FilterBuildItem registerBackChannelLogoutHandler(BeanContainerBuildItem beanCont
return new FilterBuildItem(handler, FilterBuildItem.AUTHORIZATION - 50);
}

@BuildStep
void supportIdentityUpdateForWebSocketConnections(Capabilities capabilities,
BuildProducer<AdditionalBeanBuildItem> additionalBeanProducer) {
if (capabilities.isPresent(Capability.WEBSOCKETS_NEXT)) {
additionalBeanProducer.produce(AdditionalBeanBuildItem.unremovableOf(WebSocketIdentityUpdateProvider.class));
}
}

private static boolean areEagerSecInterceptorsSupported(Capabilities capabilities,
VertxHttpBuildTimeConfig httpBuildTimeConfig) {
if (httpBuildTimeConfig.auth().proactive()) {
Expand Down
4 changes: 4 additions & 0 deletions extensions/oidc/runtime/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc-common</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next-spi</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye</groupId>
<artifactId>smallrye-jwt</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
package io.quarkus.oidc.runtime;

import static io.quarkus.runtime.configuration.DurationConverter.parseDuration;
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
Expand All @@ -19,8 +16,6 @@
import io.quarkus.arc.Arc;
import io.quarkus.arc.SyntheticCreationalContext;
import io.quarkus.arc.runtime.BeanContainer;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.Oidc;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TenantIdentityProvider;
Expand All @@ -29,13 +24,8 @@
import io.quarkus.runtime.annotations.RuntimeInit;
import io.quarkus.runtime.annotations.StaticInit;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.security.runtime.SecurityConfig;
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
import io.quarkus.tls.TlsConfigurationRegistry;
import io.smallrye.mutiny.Uni;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.ext.web.RoutingContext;
Expand Down Expand Up @@ -188,51 +178,4 @@ public Handler<RoutingContext> getBackChannelLogoutHandler(BeanContainer beanCon
return beanContainer.beanInstance(BackChannelLogoutHandler.class);
}

private static final class TenantSpecificOidcIdentityProvider extends OidcIdentityProvider
implements TenantIdentityProvider {

private final String tenantId;
private final BlockingSecurityExecutor blockingExecutor;

private TenantSpecificOidcIdentityProvider(String tenantId) {
super(Arc.container().instance(DefaultTenantConfigResolver.class).get(),
Arc.container().instance(BlockingSecurityExecutor.class).get());
this.blockingExecutor = Arc.container().instance(BlockingSecurityExecutor.class).get();
this.tenantId = tenantId;
}

@Override
public Uni<SecurityIdentity> authenticate(AccessTokenCredential token) {
return authenticate(new TokenAuthenticationRequest(token));
}

@Override
protected Uni<TenantConfigContext> resolveTenantConfigContext(TokenAuthenticationRequest request,
AuthenticationRequestContext context) {
return tenantResolver.resolveContext(tenantId).onItem().ifNull().failWith(new Supplier<Throwable>() {
@Override
public Throwable get() {
return new OIDCException("Failed to resolve tenant context");
}
});
}

@Override
protected Map<String, Object> getRequestData(TokenAuthenticationRequest request) {
RoutingContext context = getRoutingContextAttribute(request);
if (context != null) {
return context.data();
}
return new HashMap<>();
}

private Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest request) {
return authenticate(request, new AuthenticationRequestContext() {
@Override
public Uni<SecurityIdentity> runBlocking(Supplier<SecurityIdentity> function) {
return blockingExecutor.executeBlocking(function);
}
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.quarkus.oidc.runtime;

import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;

import java.util.Map;
import java.util.function.Supplier;

import io.quarkus.arc.Arc;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.TenantIdentityProvider;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

final class TenantSpecificOidcIdentityProvider extends OidcIdentityProvider
implements TenantIdentityProvider {

private final String tenantId;
private final BlockingSecurityExecutor blockingExecutor;

TenantSpecificOidcIdentityProvider(String tenantId, DefaultTenantConfigResolver resolver,
BlockingSecurityExecutor blockingExecutor) {
super(resolver, blockingExecutor);
this.blockingExecutor = blockingExecutor;
this.tenantId = tenantId;
}

TenantSpecificOidcIdentityProvider(String tenantId) {
this(tenantId, Arc.container().instance(DefaultTenantConfigResolver.class).get(),
Arc.container().instance(BlockingSecurityExecutor.class).get());
}

@Override
public Uni<SecurityIdentity> authenticate(AccessTokenCredential token) {
return authenticate(new TokenAuthenticationRequest(token));
}

@Override
protected Uni<TenantConfigContext> resolveTenantConfigContext(TokenAuthenticationRequest request,
AuthenticationRequestContext context) {
return tenantResolver.resolveContext(tenantId).onItem().ifNull().failWith(new Supplier<Throwable>() {
@Override
public Throwable get() {
return new OIDCException("Failed to resolve tenant context");
}
});
}

@Override
protected Map<String, Object> getRequestData(TokenAuthenticationRequest request) {
RoutingContext context = getRoutingContextAttribute(request);
if (context != null) {
return context.data();
}
return Map.of();
}

private Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest request) {
return authenticate(request, new AuthenticationRequestContext() {
@Override
public Uni<SecurityIdentity> runBlocking(Supplier<SecurityIdentity> function) {
return blockingExecutor.executeBlocking(function);
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package io.quarkus.oidc.runtime;

import static io.quarkus.oidc.common.runtime.OidcConstants.INTROSPECTION_TOKEN_SUB;
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import org.eclipse.microprofile.jwt.JsonWebToken;

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TokenIntrospection;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
import io.quarkus.websockets.next.runtime.spi.security.WebSocketIdentityUpdateRequest;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class WebSocketIdentityUpdateProvider implements IdentityProvider<WebSocketIdentityUpdateRequest> {

@Inject
DefaultTenantConfigResolver resolver;

@Inject
BlockingSecurityExecutor blockingExecutor;

WebSocketIdentityUpdateProvider() {
}

@Override
public Class<WebSocketIdentityUpdateRequest> getRequestType() {
return WebSocketIdentityUpdateRequest.class;
}

@Override
public Uni<SecurityIdentity> authenticate(WebSocketIdentityUpdateRequest request,
AuthenticationRequestContext authenticationRequestContext) {
return authenticate(request.getCredential().getToken(), getRoutingContextAttribute(request))
.onItem().transformToUni(newIdentity -> {
if (newIdentity.getPrincipal() instanceof JsonWebToken newJwt
&& request.getCurrentSecurityIdentity().getPrincipal() instanceof JsonWebToken previousJwt) {
String currentSubject = newJwt.getSubject();
String previousSubject = previousJwt.getSubject();
if (currentSubject == null || !currentSubject.equals(previousSubject)) {
return Uni.createFrom().failure(new AuthenticationFailedException(
"JWT token claim 'sub' value '%s' is different to the previous claim value '%s'"
.formatted(currentSubject, previousSubject)));
} else {
return Uni.createFrom().item(newIdentity);
}
}

TokenIntrospection introspection = OidcUtils.getAttribute(newIdentity, OidcUtils.INTROSPECTION_ATTRIBUTE);
if (introspection != null) {
String sub = introspection.getString(INTROSPECTION_TOKEN_SUB);
if (sub != null && !sub.isEmpty()) {
TokenIntrospection previousIntrospection = OidcUtils
.getAttribute(request.getCurrentSecurityIdentity(), OidcUtils.INTROSPECTION_ATTRIBUTE);
if (previousIntrospection == null
|| !sub.equals(previousIntrospection.getString(INTROSPECTION_TOKEN_SUB))) {
return Uni.createFrom().failure(new AuthenticationFailedException(
"Token introspection result claim 'sub' value '%s' is different to the previous claim value '%s'"
.formatted(sub, previousIntrospection == null ? null
: previousIntrospection.getString(INTROSPECTION_TOKEN_SUB))));
} else {
return Uni.createFrom().item(newIdentity);
}
}
}

return Uni.createFrom().failure(new AuthenticationFailedException(
"Cannot verify that updated identity represents same subject as the 'sub' claim is not available"));
});
}

private Uni<SecurityIdentity> authenticate(String accessToken, RoutingContext routingContext) {
final OidcTenantConfig tenantConfig = routingContext.get(OidcTenantConfig.class.getName());
if (tenantConfig == null) {
return Uni.createFrom().failure(new AuthenticationFailedException(
"Cannot update SecurityIdentity because OIDC tenant wasn't resolved for current WebSocket connection"));
}
final var tenantId = tenantConfig.tenantId().get();
final var identityProvider = new TenantSpecificOidcIdentityProvider(tenantId, resolver, blockingExecutor);
final var credential = new AccessTokenCredential(accessToken);
return identityProvider.authenticate(credential);
}
}
Loading
Loading