-
Notifications
You must be signed in to change notification settings - Fork 3k
WebSocket Next: enable users to update SecurityIdentity before previous bearer access token expires #47675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
sberyozkin
merged 1 commit into
quarkusio:main
from
michalvavrik:feature/ws-next-refresh-token-sub-protocol
Jul 7, 2025
Merged
WebSocket Next: enable users to update SecurityIdentity before previous bearer access token expires #47675
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) { | ||
michalvavrik marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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: { | ||
sberyozkin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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. | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
70 changes: 70 additions & 0 deletions
70
...idc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantSpecificOidcIdentityProvider.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}); | ||
} | ||
} |
92 changes: 92 additions & 0 deletions
92
...s/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/WebSocketIdentityUpdateProvider.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
sberyozkin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.