REST WebSocket
View MarkdownStatus: Stable Package:
dev.vertique.rest.websocketArtifact:vertique-rest-websocketDepends on: rest-core, rest-security, core, context, security-core, security-runtime, logging API reference: javadoc.io
vertique-rest-websocket is the annotation-driven WebSocket transport for Vertique. You write a
plain class annotated with @WebSocketEndpoint, add lifecycle methods (@OnOpen, @OnMessage,
@OnClose, @OnError), and contribute it to a Dagger multibinding. The module mounts a dedicated
Vert.x sub-router ahead of the JAX-RS router, authenticates and authorizes the HTTP upgrade request
using the same security annotations JAX-RS resources use, and propagates the request’s ambient
context — security context, correlation, MDC — into the connection for its whole lifetime.
It is not a JAX-RS transport: WebSocket endpoints are not @Path resources, they are not
described in the generated OpenAPI document, and there is no per-message authorization. Every
security decision is made once, at upgrade time, before the 101 response is written.
When To Use It
Section titled “When To Use It”Install this module when the application serves WebSocket connections and wants Vertique’s
authentication, typed message handling, and context propagation on them rather than hand-rolling a
ServerWebSocket handler.
| You also want | Also install |
|---|---|
| Bearer-token authentication on the upgrade | dev.vertique:vertique-rest-auth-jwt (or another module contributing a RouteAuthHandler) |
Role/scope enforcement and a resolved SecurityContext |
dev.vertique:vertique-rest-security (AuthModule + SecurityModule) |
@RequiresAction action gates on endpoints |
dev.vertique:vertique-security-runtime (SecurityAuthzModule) |
A reusable @RequiresPolicy on endpoints |
dev.vertique:vertique-security-core (policy types); dev.vertique:vertique-rest-security for a role/scope policy (enforcement); add vertique-security-runtime when the policy carries an action |
| Bean Validation on typed messages | dev.vertique:vertique-validation (ValidationModule) |
| Canonicalization/sanitization of inbound values | dev.vertique:vertique-sanitization (SanitizationModule) |
Without a security module the endpoints still work, unauthenticated. That is a supported configuration for local development and for genuinely public sockets.
Core Concepts
Section titled “Core Concepts”The upgrade is the security boundary
Section titled “The upgrade is the security boundary”A WebSocket endpoint’s route runs four handlers in a fixed order before the socket exists:
- Authentication — installed only when the endpoint’s policy is
@RolesAllowed/@Authorized(or when a class-level@RequiresActionis present, or when a class-level@RequiresPolicyresolves to a role, scope or authenticated-caller requirement, or carries an action). It sets the Vert.x user and appends authentication evidence. - Identity resolution — resolves the framework
SecurityContextfrom that evidence. - Authorization — evaluates the role/scope policy AND, when present, the
@RequiresActionaction gate. Both must pass. - Upgrade — only now is
HttpServerRequest.toWebSocket()called.
A rejected caller therefore never reaches step 4 and never sees a 101 response. The client observes an ordinary HTTP failure status on the handshake.
When AuthModule is installed, steps 2–3 are supplied by the security-owned
dev.vertique.rest.security.IdentityPipelineFactory — the single assembly point every transport
(REST, MCP, WebSocket) reads identity resolution and authorization from. The handler this module
installs is assembled for invocation origin websocket (DispatchBoundary.WEBSOCKET) with
identity-snapshot capture always off, regardless of whether the application binds a capture —
a channel upgrade establishes a long-lived identity, not the per-request lifecycle that capture is
defined for. AuthorizationDecisionEvents and audit attributes recorded for a WebSocket upgrade
therefore carry origin kind websocket, distinguishing them from REST’s rest.
Fail-closed registration when the pipeline is absent. Without AuthModule (no
IdentityPipelineFactory bound), registering an endpoint whose security policy is restrictive
(@DenyAll, @RolesAllowed, @Authorized, or a @RequiresPolicy resolving to one of them) or
that declares a class-level @RequiresAction fails startup with one aggregated
IllegalStateException naming every offending endpoint class and its policy — see
Startup failures. Unannotated endpoints are unaffected and keep serving
unauthenticated.
There is no per-message authorization
Section titled “There is no per-message authorization”Authorization runs once, at upgrade. @RequiresAction and @RequiresPolicy are accepted at class
level only; placing either on @OnOpen, @OnMessage, @OnClose, or @OnError fails startup
rather than being silently ignored. If a connection’s privileges must change mid-stream, refresh the
channel identity (see Identity refresh) — do not expect the framework to
re-check a gate per frame.
Typed policies gate admission only
Section titled “Typed policies gate admission only”A class-level @RequiresPolicy names a reusable access policy (an interface extending
AccessPolicy whose annotations declare the requirements) and resolves at startup to the same role,
scope, public/deny and action gate the inline annotations produce. It decides whether the upgrade is
admitted and nothing else.
@RequiresPolicy(EditorPolicy.class)@WebSocketEndpoint("/ws/documents/{id}")public class DocumentEndpoint {
private final DocumentService documents;
@OnMessage Future<Void> onMessage(WebSocketSession session, DocumentEdit edit) { // The upgrade was admitted by EditorPolicy; the service re-checks its own policy per call. return documents.apply(edit); }}Admission and service access are separate decisions:
- A permitted upgrade grants no standing permission. A message that calls a policy-protected service is checked again at that service, with the caller’s identity at that moment; a refused call has no effect.
- Handler code is not intercepted. Arbitrary code in your
@OnMessagemethod and ordinary Java calls on service implementations are not enforced automatically. Protect business work with a policy on the service and call it through the generated or proxy service client.
Frames are gated until @OnOpen completes
Section titled “Frames are gated until @OnOpen completes”The socket is paused the moment the handshake succeeds and is resumed only after your @OnOpen
method has completed successfully. A message sent by the peer immediately after connecting is
queued, not dropped, and is delivered after @OnOpen. If @OnOpen throws or returns a failed
future, the socket is never resumed and is closed with code 1011.
Context is carried across the handshake
Section titled “Context is carried across the handshake”Everything bound to the request’s ambient context at upgrade time — the SecurityContext,
CorrelationContext, MDC keys, and any other typed context value — is captured and re-bound onto
the connection for its lifetime. Read it inside any lifecycle callback:
@OnMessagevoid onMessage(WebSocketSession session, SecurityContext ctx, ChatMessage msg) { ctx.identity().subject().ifPresent(p -> log.info("message from {}", p));}WebSocketSession deliberately has no securityContext() accessor and no way to close the live
scope. Read the context one of three ways:
| Access | Notes |
|---|---|
A dev.vertique.security.SecurityContext lifecycle-method parameter |
Simplest; null when no security module is installed |
An injected dev.vertique.rest.core.security.SecurityRuntime, then current() |
SecurityRuntime is an interface bound only when SecurityModule is installed |
dev.vertique.context.ContextValues.current(SecurityContext.class) |
Static; returns Optional<SecurityContext> |
All three re-read the ambient value on every invocation, so they observe an identity refresh.
Getting Started
Section titled “Getting Started”@WebSocketEndpoint("/ws/chat/{roomId}")@RolesAllowed("chat:connect")@Singletonpublic class ChatEndpoint {
private final ChatRoomRegistry registry;
@Inject ChatEndpoint(ChatRoomRegistry registry) { this.registry = registry; }
@OnOpen void onOpen(WebSocketSession session, @PathParam("roomId") String roomId) { registry.join(roomId, session); }
@OnMessage Future<Void> onMessage(WebSocketSession session, ChatMessage msg) { String roomId = session.pathParams().get("roomId"); return registry.broadcast(roomId, msg); }
@OnClose void onClose(WebSocketSession session) { registry.leave(session.id()); }
@OnError void onError(WebSocketSession session, Throwable error) { log.error("WebSocket error on session {}", session.id(), error); }}Contribute the endpoint instance:
@Modulepublic abstract class ChatModule {
@Provides @IntoSet @WebSocketEndpoints static Object chatEndpoint(ChatEndpoint endpoint) { return endpoint; }}Wire the component:
@Singleton@Component(modules = {VertxModule.class, ConfigParsingModule.class, RestModule.class, JwtAuthModule.class, WebSocketModule.class, ChatModule.class, AppModule.class})interface AppComponent { HttpVerticle httpVerticle();}WebSocketModule lives in dev.vertique.rest.websocket.dagger — not the module’s base package —
and injects a ConfigParser, so ConfigParsingModule (from dev.vertique:vertique-config-core)
must be in the graph. JwtAuthModule supplies the authentication handler and transitively includes
AuthModule + SecurityModule; drop it for an unauthenticated socket.
When no endpoint is contributed the module registers no mount at all, so including it in a component that has no WebSocket endpoints costs nothing.
Key Classes
Section titled “Key Classes”@WebSocketEndpoint
Section titled “@WebSocketEndpoint”Class-level annotation marking a WebSocket endpoint.
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)public @interface WebSocketEndpoint { String value(); String authScheme() default "";}| Attribute | Default | Meaning |
|---|---|---|
value |
(required) | Path template. {name} placeholders each match exactly one path segment. |
authScheme |
"" |
Selects which registered RouteAuthHandler authenticates the upgrade, by schemeName(). Empty means auto-select: exactly one handler must be registered, otherwise registration fails. |
The path template is relative to websocket.basePath. Combine the annotation with the JAX-RS
security annotations (@RolesAllowed, @PermitAll, @DenyAll, @Authorized) and, optionally,
@RequiresAction — all at class level — or replace them with a single class-level
@RequiresPolicy, which cannot be mixed with them.
Lifecycle annotations
Section titled “Lifecycle annotations”@OnOpen, @OnMessage, @OnClose, and @OnError are method-level markers with no attributes. At
most one method per annotation per endpoint; a second one fails startup. All four are optional.
| Annotation | Fires when |
|---|---|
@OnOpen |
The handshake succeeded and the session context is bound. Runs before any frame is delivered. |
@OnMessage |
A complete text or binary message arrives. |
@OnClose |
The connection closed, from either side. |
@OnError |
A socket-level error occurs, an inbound message fails to deserialize or validate, or another lifecycle method throws or returns a failed future. |
Return type must be void or Future<Void>; anything else fails startup. Only @OnOpen and
@OnClose have their futures awaited — @OnOpen’s gates resume(), @OnClose’s gates resource
release. A failed future from @OnMessage or @OnError is logged and dispatched to @OnError.
Parameters are resolved by type and annotation, in this order:
| Parameter shape | Bound to |
|---|---|
WebSocketSession (or a supertype) |
The current session |
Throwable (or a subtype) |
The error — only on @OnError; otherwise left null |
dev.vertique.security.SecurityContext |
SecurityRuntime.current() at invocation time; null when no security module is installed |
@PathParam("name") T |
The extracted path segment, converted to T |
| any other parameter | The deserialized message — only on @OnMessage |
There is no query-parameter, header, or body-annotation injection. Read the upgrade request’s query
string and headers from session.queryParams() and session.headers().
@PathParam targets support String, int/Integer, and long/Long. Any other declared type
throws IllegalArgumentException when the callback is invoked. A @PathParam name that is not a
placeholder in the endpoint’s path template fails startup.
Message typing
Section titled “Message typing”The @OnMessage message parameter is the first parameter that is not a WebSocketSession, a
Throwable, a SecurityContext, or @PathParam-annotated. Its declared type selects the wire
handling. Exactly one such parameter is allowed: a second payload-eligible parameter is rejected at
startup with a ConfigurationException naming the method. Put the sanitize/canonicalize chain on
the message parameter or on the method:
| Declared type | Handling |
|---|---|
String |
Text frames delivered verbatim, no JSON decoding |
io.vertx.core.buffer.Buffer |
The endpoint listens for binary messages instead of text |
| anything else | Text frames decoded from JSON into that type |
An endpoint listens for either text or binary messages, never both — the Buffer parameter is what
switches it. With no message parameter (or no @OnMessage at all) the endpoint receives nothing.
JSON encoding and decoding — inbound typed messages and session.send(Object) — use the process
JSON codec’s mapper (VertiqueJson.mapper()), the same mapper every other unmanaged framework path
runs on: whatever profile json.systemProfile selects applies to WebSocket payloads too.
WebSocket message binding has no request-validation gate in front of it, so it is
binder-authoritative: under an enum-lenient system profile (the reserved vertique profile or
any custom profile that enables READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE), an unrecognized
enum string binds to the field’s @JsonEnumDefaultValue instead of being rejected — the same enum
leniency every process-codec path exposes. Process-codec paths also accept JSON comments under the
default system profile, exactly as they do today; a comment-strict custom profile turns that off.
A message that fails to deserialize or fails Bean Validation is dropped: @OnError is invoked
with the exception when declared, and @OnMessage is not called. The connection stays open.
WebSocketSession
Section titled “WebSocketSession”The handle passed to lifecycle methods. Instances are scoped to one connection and are not thread-safe — touch them only on the connection’s event loop.
public interface WebSocketSession { String id(); // random UUID, stable for the connection ServerWebSocket raw(); // underlying Vert.x socket String path(); // request path from the upgrade Map<String, String> pathParams(); // immutable MultiMap queryParams(); // from the upgrade request URI MultiMap headers(); // from the upgrade request Map<String, Object> attributes(); // mutable, application-owned
Future<Void> send(Object message); // serialized to a JSON text frame Future<Void> sendText(String text); Future<Void> sendBinary(Buffer data);
Future<Void> close(); // close code 1000 Future<Void> close(short statusCode, String reason); boolean isOpen();}id() is also the channel id used by ChannelIdentityManager, so it is what you pass to
refreshIdentity.
WebSocketConfig
Section titled “WebSocketConfig”Read from the websocket section of the application config.
| Key | Type | Default | Description |
|---|---|---|---|
websocket.basePath |
String |
"/*" |
Path at which the WebSocket sub-router is mounted. Must start with / and end with /*. |
{ "websocket": { "basePath": "/ws/*" }}This module sets no frame-size or message-size limits of its own. Those are Vert.x server options — configure them on the HTTP server, not here.
WebSocketMount.Factory
Section titled “WebSocketMount.Factory”Injectable factory for building additional mounts programmatically, e.g. to mount a second endpoint
group at a different path or ahead of another RouterMount.
public WebSocketMount create(String mountPath, Set<Object> endpoints);public WebSocketMount create(String mountPath, Set<Object> endpoints, int priority);The default mount created from @WebSocketEndpoints uses priority -100, which places it ahead of
the JAX-RS mount so WebSocket paths are matched before catch-all REST routes. Contribute a
hand-built mount to the Set<RouterMount> multibinding:
@Provides@IntoSetstatic RouterMount adminSocketMount(WebSocketMount.Factory factory, AdminEndpoint endpoint) { return factory.create("/admin/ws/*", Set.of(endpoint), -200);}Upgrade failure types
Section titled “Upgrade failure types”A client connecting to a Vertique WebSocket endpoint sees the server’s refusal through one of several raw Vert.x/Netty exception shapes depending on timing. Normalize them instead of pattern-matching third-party types:
public abstract sealed class WebSocketUpgradeException extends TechnicalException permits WebSocketUpgradeRejected, WebSocketUpgradeTransportFailure {}
public final class WebSocketUpgradeRejected extends WebSocketUpgradeException { public int status(); // e.g. 401, 403, 404, 503}
public final class WebSocketUpgradeTransportFailure extends WebSocketUpgradeException {}
public final class WebSocketUpgradeExceptions { public static WebSocketUpgradeException translate(Throwable err);}translate returns a WebSocketUpgradeException unchanged, preserves the structured status of a
Vert.x UpgradeRejectedException, otherwise scans the message for a standalone 1xx–5xx token and
returns WebSocketUpgradeRejected when one is found. With no recoverable status it returns
WebSocketUpgradeTransportFailure — typically “the server closed the socket mid-handshake”, which is
observable but says nothing about why. Retry before concluding anything from it.
client.connect(options).onFailure(err -> { WebSocketUpgradeException upgrade = WebSocketUpgradeExceptions.translate(err); if (upgrade instanceof WebSocketUpgradeRejected rejected && rejected.status() == 401) { refreshTokenAndReconnect(); }});These types live in dev.vertique.rest.websocket.transport and describe the client side of a
handshake. Nothing on the server path throws them.
Extension Points
Section titled “Extension Points”@WebSocketEndpoints (multibinding)
Section titled “@WebSocketEndpoints (multibinding)”The only registration surface. Contribute each endpoint instance as Object into the
@WebSocketEndpoints-qualified set; Dagger injects the endpoint’s own dependencies normally.
@Provides@IntoSet@WebSocketEndpointsstatic Object chatEndpoint(ChatEndpoint endpoint) { return endpoint;}The qualifier is dev.vertique.rest.websocket.dagger.WebSocketEndpoints. Endpoints are scanned and
validated once, at router construction — a structural violation fails startup, not the first
connection.
RouteAuthHandler (consumed, not declared here)
Section titled “RouteAuthHandler (consumed, not declared here)”The upgrade’s authentication handler comes from the Set<RouteAuthHandler> multibinding declared by
dev.vertique:vertique-rest-security. dev.vertique:vertique-rest-auth-jwt contributes the bearer
handler; contribute your own to authenticate upgrades with a different scheme, then name it with
@WebSocketEndpoint(authScheme = "...") when more than one is registered.
JAX-RS Integration
Section titled “JAX-RS Integration”WebSocket endpoints are not JAX-RS resources, but they reuse two annotation families so an application declares security the same way on both transports:
| Annotation | Placement | Effect at upgrade |
|---|---|---|
@PermitAll |
class | No authentication handler, no authorization gate |
@DenyAll |
class | Every upgrade is refused with 403 |
@RolesAllowed({...}) |
class | Authenticate, then require any one of the listed roles |
@Authorized(scopes = {...}) |
class | Authenticate, then enforce the declared scopes |
@RequiresAction("...") |
class | Authenticate, then evaluate the action gate — AND-composed with the above |
@RequiresPolicy(Policy.class) |
class | Resolves to the policy’s role/scope/public/deny predicates and optional action gate, then behaves as the matching rows above; not combinable with the inline annotations |
@PathParam("name") |
lifecycle method parameter | Binds a path-template placeholder |
@ValidateWith(groups = {...}) |
@OnMessage method |
Selects Bean Validation groups |
Method-level security annotations (including @RequiresPolicy) on lifecycle methods fail startup —
the endpoint class is the only site for the security policy above. Input canonicalization and
sanitization policies are resolved separately, and do read method- and parameter-level
declarations — see Validation and Input Processing.
Validation and Input Processing
Section titled “Validation and Input Processing”Both features are optional bindings. When the providing module is absent the step is skipped silently — nothing fails.
| Feature | Install | Applies to |
|---|---|---|
| Bean Validation | ValidationModule |
The deserialized @OnMessage payload |
| Canonicalization / sanitization | SanitizationModule |
The @OnMessage text payload and @PathParam string values; binary (Buffer) payloads are never processed |
Canonicalizers and sanitizers see the value’s provenance in their InputValueContext:
@OnMessage values — both a raw String payload and the decoded intermediate of a typed
message — report InputLocation.PAYLOAD, and @PathParam values report InputLocation.PATH.
Message values reported BODY before PAYLOAD existed: a custom processor that branches on
InputLocation.BODY must also handle PAYLOAD to keep covering messages.
Bean Validation runs after deserialization (and after sanitization when both are installed). A
violation raises BeanValidationException, which is routed to @OnError; the message is discarded.
public record CreateRoomMessage( @NotBlank @Size(max = 64) String name, @Min(1) @Max(100) int maxParticipants) {}
@OnMessage@ValidateWith(groups = {Default.class, StrictInput.class})@Canonicalize(NfkcCanonicalize.class)@Sanitize(StripControlCharsSanitize.class)Future<Void> onMessage(WebSocketSession session, CreateRoomMessage msg) { return session.send(new RoomCreated(msg.name()));}
@OnErrorvoid onError(WebSocketSession session, Throwable error) { if (error instanceof BeanValidationException ex) { session.send(new ErrorResponse(ex.violations())); }}Policy resolution: class, then method, then parameter. @Canonicalize/@Sanitize are resolved
the same way here as on a JAX-RS resource. A declaration on the endpoint class applies to every
lifecycle method; a declaration on a lifecycle method replaces the class-level chain for that method;
a declaration on a single parameter replaces the method’s chain for that one argument. Each level is
read across the endpoint’s whole type hierarchy — a declaration on an implemented interface’s method,
on a superclass, or behind a composed annotation counts as a declaration at that level — and the
nearest declaration wins. Everything is resolved once per endpoint at registration, so no policy
annotation is inspected while a message is being handled.
An override may replace but never remove an inherited policy. @SkipCanonicalization/
@SkipSanitization opt an element out of the chain it would otherwise inherit from the level above:
a skip on a method exempts that method from the class-level chain, and a skip on a parameter exempts
that argument from the method’s chain. Declaring a skip and the matching @Canonicalize/@Sanitize
on the same element — including one inherited from an interface or superclass and the other on the
override — is a contradiction and fails startup (see Startup failures). Remove
one of the two, or declare the replacement chain instead of a skip.
A parameter-level @SkipCanonicalization/@SkipSanitization now takes effect. Earlier releases
resolved WebSocket policies from the lifecycle method alone, so a skip on a parameter was silently
inert and the value was processed anyway; today it removes processing for that argument only.
@PathParam and message values each use their own parameter’s policies. A path parameter is
processed with the chain resolved for that parameter — its own declaration, else the method’s, else
the class’s — so two placeholders bound by one method can be normalized differently, and @OnOpen
and @OnMessage can each declare their own chain for the same placeholder. The message payload is
processed with the chain resolved for the message parameter. Binary payloads are the exception: a
Buffer message parameter is delivered to @OnMessage exactly as it arrived, because
canonicalization and sanitization operate on string values.
Composed policy annotations are honored. A custom annotation meta-annotated with
@Canonicalize/@Sanitize — the usual way to name a reusable chain — declares that chain on a
class, a lifecycle method, or a parameter exactly as the bare annotation does, matching REST. The
startup gate reads the very policies the message path later applies, so what fails the build and what
runs on a message always agree.
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Sanitize(StripAllHtmlSanitizer.class)public @interface SafeText {}
@OnMessage@SafeText // identical to @Sanitize(StripAllHtmlSanitizer.class)void onMessage(WebSocketSession session, String text) { }Renamed message fields are covered. A field-level policy is declared on a Java property, while an
incoming message is keyed by whatever Jackson publishes — @JsonProperty("user_name"), a naming
strategy, or a @JsonAlias. Messages are bound through the process JSON codec’s VertiqueJson.mapper(), and
that mapper’s own property introspection is what maps each wire key back onto the Java property whose
policies apply (JacksonFieldNameResolver, from dev.vertique:vertique-json, implementing the
dev.vertique.core.sanitization.InputFieldNameResolver contract). A @Sanitize on a
renamed field therefore runs exactly as it would on an unrenamed one, with no extra declaration.
The projection is computed once per endpoint set when the registrar is built (the EDGE startup
phase) from the mapper installed at that moment, while each frame binds through the mapper current at
request time. A later same-id swap of the process codec that changes property naming would leave the
projection stale — a renamed field’s policy could then silently not run — so such a swap must keep
the replaced mapper’s naming; the framework never performs one after startup.
public record ProfileMessage( @JsonProperty("display_name") @Sanitize(StripAllHtmlSanitizer.class) String displayName) {}// {"display_name": "<b>ada</b>"} -> displayName == "ada"Each declared message type’s projection is composed at endpoint registration, not on the message path.
The engine decides which types get composed, not the registrar: registration hands each declared
message type to InputObjectProcessor.precomputeFieldNameResolution, which prepares the resolver for
every owner type its own descent may consult for that message. The postcondition is that no statically
knowable owner is left to introspect on the event loop — an array message type reduces to its
component and a scalar such as the default String simply has nothing beneath it, both by the engine’s
own classification. The vertique-input-processing reference documents the owner set and its bounds in
full.
Two startup failures follow (see Startup failures), both of which previously surfaced per message: a type in that set whose names cannot be projected, and a reachable type whose policy annotations conflict — preparing the owner set resolves that type’s policy metadata.
Three shapes a declared policy still does not reach. A Map-typed field, an Object-typed
field, and a concrete @JsonTypeInfo subtype’s own fields all leave the field with its inherited
method- and type-level chains and nothing else. Nothing fails and nothing is logged, so a stranded policy
on one of these is invisible until the message that mattered gets through. A renamed key, a key a codec
promoted out of an @JsonUnwrapped member, and a key matched case-insensitively are all covered
now, and a governed field the mapper binds under a different property name — @Sanitize on
streetName behind setStreet — fails registration rather than passing silently. The vertique-input-processing reference documents each shape, what still applies, and how to
stay inside the covered set.
Identity Refresh
Section titled “Identity Refresh”When AuthModule is installed, each connection is registered as a channel with
dev.vertique.security.channel.ChannelIdentityManager (from dev.vertique:vertique-security-core).
Use it to swap a live connection’s identity after, for example, a token rotation:
@Singletonpublic class SessionRefresher {
private final ChannelIdentityManager channels;
@Inject SessionRefresher(ChannelIdentityManager channels) { this.channels = channels; }
public Future<Void> refresh(WebSocketSession session, SecurityContext newContext) { return channels.refreshIdentity(session.id(), newContext); }}Compose on the returned future — it completes only once the new identity is in effect. The refresh
replaces the SecurityContext only; correlation and MDC bound at upgrade survive it.
The Vert.x authorization import (opt-in via VertxAuthorizationImportModule from
dev.vertique:vertique-rest-security) runs during identity resolution at upgrade time only. A
refresh rebinds the supplied SecurityContext as-is — it does not re-invoke identity resolvers or
contributed AuthorizationProviders. Resolve any refreshed authorities into the new context before
calling refreshIdentity.
@OnClose runs before the channel is deregistered, so your close handler still observes the
authenticated SecurityContext rather than an anonymous one. Cleanup — the channel-closed event,
expiry-timer cancellation, and scope release — happens after your close logic has settled.
Without AuthModule there is no ChannelIdentityManager binding and no channel registration; the
connection’s context scope is released directly on close.
Module Dagger Bindings
Section titled “Module Dagger Bindings”WebSocketModule (dev.vertique.rest.websocket.dagger) includes RestCoreModule and
SecurityEventsModule. It declares exactly two @Multibinds and six @BindsOptionalOf
declarations; identity resolution, authorization, claim mapping, and Vert.x authorization import are
no longer separately declared here — they live entirely behind the single
IdentityPipelineFactory optional binding, owned by dev.vertique:vertique-rest-security’s
AuthModule (issue #256; a second module declaring those collaborators would re-create the duplicate
assembly that change closed).
| Binding | Purpose |
|---|---|
@Multibinds @WebSocketEndpoints Set<Object> |
Empty default; applications contribute endpoints |
@Multibinds Set<RouteAuthHandler> |
Empty default so the graph resolves with no auth module present; AuthModule (or dev.vertique:vertique-rest-auth-jwt) contributes into the same set |
WebSocketConfig (@Singleton) |
Parsed from the websocket config section |
@ElementsIntoSet Set<RouterMount> |
Contributes one WebSocketMount at priority -100, or nothing when no endpoint is contributed |
Optional bindings (@BindsOptionalOf), each absent unless the named module is in the component. All
coalesce with the same declaration in AuthModule when both are present.
| Optional binding | Supplied by | Absent means |
|---|---|---|
IdentityPipelineFactory (dev.vertique.rest.security.IdentityPipelineFactory) |
AuthModule |
No authentication, identity resolution, or authorization on any endpoint; a restrictive/@RequiresAction endpoint fails registration closed instead of registering unauthenticated (see The upgrade is the security boundary) |
ChannelIdentityManager |
AuthModule |
No channel registration; no identity refresh |
Authorizer |
SecurityAuthzModule |
Any endpoint declaring @RequiresAction fails startup |
ActionRegistry |
SecurityAuthzModule |
Same |
BeanValidator |
ValidationModule |
Messages are not validated |
InputObjectProcessor (dev.vertique.input.processing.InputObjectProcessor) |
SanitizationModule |
Messages and path parameters are not sanitized — and any endpoint that declares a policy fails startup rather than accepting messages unprocessed |
Failures, Constraints, and Common Mistakes
Section titled “Failures, Constraints, and Common Mistakes”Startup failures
Section titled “Startup failures”All of these are raised while the router is built, so a misconfigured endpoint never serves traffic.
| Condition | Thrown |
|---|---|
Endpoint instance not annotated @WebSocketEndpoint |
IllegalArgumentException |
| Two methods carry the same lifecycle annotation | IllegalArgumentException |
A lifecycle method returns something other than void or Future |
IllegalArgumentException |
@PathParam("x") names no placeholder in the path template |
IllegalArgumentException |
| Conflicting security annotations on the class | IllegalArgumentException |
@RolesAllowed with an empty value list |
IllegalArgumentException |
@DenyAll, @RolesAllowed, @PermitAll, or @Authorized on a lifecycle method |
IllegalArgumentException |
@RequiresPolicy on a lifecycle method |
IllegalArgumentException |
@RequiresPolicy naming an invalid policy, distinct policies across the endpoint’s type hierarchy, or a policy mixed with an inline security annotation |
IllegalArgumentException |
@RequiresAction on a lifecycle method |
IllegalArgumentException |
@RequiresAction that is not a canonical action reference |
IllegalArgumentException |
@RequiresAction combined with @PermitAll or @DenyAll |
IllegalArgumentException |
@RequiresAction with no ActionRegistry installed |
IllegalArgumentException |
@RequiresAction naming an action absent from the ActionRegistry |
IllegalArgumentException |
@RequiresAction with no authorization enforcement pipeline installed |
IllegalStateException |
@RequiresPolicy resolving to a restrictive policy or an action, with no authorization enforcement pipeline installed |
IllegalStateException |
@RequiresAction with an ActionRegistry but no Authorizer |
IllegalStateException |
One or more endpoints declare a restrictive policy (@DenyAll, @RolesAllowed, @Authorized) or class-level @RequiresAction, but no IdentityPipelineFactory is bound (no AuthModule) — checked for every contributed endpoint before any authentication handler is installed; the one exception aggregates every violating endpoint class and its policy |
IllegalStateException |
Endpoint needs authentication but no RouteAuthHandler is registered |
IllegalStateException |
authScheme names no registered RouteAuthHandler |
IllegalStateException |
Several RouteAuthHandlers registered and no authScheme given |
IllegalStateException |
A wire-name projection in a message type’s owner set cannot be composed — two properties claiming one wire name, or two claiming one @JsonAlias (checked only when an InputObjectProcessor is bound) |
ConfigurationException |
A type in a message type’s owner set declares conflicting policy annotations (checked only when an InputObjectProcessor is bound) |
IllegalStateException |
| An endpoint class, lifecycle method, or lifecycle-method parameter declares both a policy annotation and the matching skip annotation anywhere in its type hierarchy | InvocationPolicyConflictException (an IllegalStateException) |
An endpoint declares a canonicalizer or sanitizer chain — on the class, a lifecycle method, or a lifecycle-method parameter, directly or through a composed annotation — or the message type declares field-level policies, while no InputObjectProcessor is bound |
ConfigurationException |
@OnMessage declares more than one payload-eligible parameter |
ConfigurationException |
Every @RequiresAction failure mode above, and the same failures for an action carried by a
@RequiresPolicy, is deliberately fail-closed: an action gate that cannot be enforced refuses to
boot rather than serving traffic with the gate silently missing. The
restrictive-policy-without-pipeline row is the same discipline applied to role/scope policies: a
@DenyAll endpoint with no AuthModule installed would otherwise register open, and a graph that
binds a RouteAuthHandler without the pipeline would authenticate but never authorize.
Connection-time outcomes
Section titled “Connection-time outcomes”| Outcome | What the client sees |
|---|---|
| Authentication or authorization denied | The security layer’s HTTP status (401 or 403) on the handshake; no 101 |
toWebSocket() failed |
HTTP 400, or Vert.x’s own answer when it already ended the response (400, 405 or 426); either way the request is unclaimed and yields one HttpRequestCompletedEvent |
Session bootstrap threw, channel registration failed, or @OnOpen failed |
Handshake succeeds, then an immediate close with code 1011 and no frames |
| Peer closed while channel registration was still in flight | @OnOpen is skipped entirely and the connection is torn down |
Normal session.close() |
Close code 1000 |
No request completion event for an upgraded connection. This module claims each successfully
upgraded request for its own lifecycle, so rest-core’s completion emitter publishes no completion
event for it — neither a RestRequestCompletedEvent nor an HttpRequestCompletedEvent — not at
the 101 and not when the connection later closes. Observe the connection through @OnOpen and
@OnClose instead and, when AuthModule registers it as a channel (see
Identity refresh), through the channel events ChannelOpenedEvent and
ChannelClosedEvent (from dev.vertique:vertique-security-core) delivered to every
SecurityEventObserver. A rejected or failed upgrade is not claimed: a handshake the security layer
rejects with 401 or 403, or one whose toWebSocket() failed and was answered with 400 by this
module or with 400, 405 or 426 by Vert.x itself, completes as an ordinary HTTP request and produces
exactly one HttpRequestCompletedEvent.
Common mistakes
Section titled “Common mistakes”- Expecting per-message authorization. There is none. A
@DenyAll,@RolesAllowed,@PermitAll,@Authorized,@RequiresAction, or@RequiresPolicyon a lifecycle method fails startup for exactly this reason — it would suggest a guarantee the transport cannot make. Put the annotation on the endpoint class instead; WebSocket authorizes once at upgrade. - Treating an admitted upgrade as permission to run protected work.
@RequiresPolicyon the endpoint only admits the connection. Put the policy on the service the message calls; code that bypasses the service client is not checked. - Mixing
@RequiresPolicywith an inline@RolesAllowed,@Authorized,@PermitAll,@DenyAllor@RequiresAction. Startup fails; declare the requirements in the policy or inline, not both. - Storing the
SecurityContextinsession.attributes()at@OnOpen. It goes stale across an identity refresh. Read it per invocation instead. - Assuming a class-level
@Canonicalize/@Sanitizeis inert. It governs every lifecycle method of the endpoint, including the@PathParamvalues they bind. Declare the chain on the one method — or the one parameter — whose input it should govern when endpoint-wide reach is not what you want. Only the bound argument is processed:session.pathParams()still returns the raw client values. - Using a skip annotation to remove an inherited chain from the same element. A method (or
parameter) carrying both
@Sanitizeand@SkipSanitization— one of them inherited from an interface or superclass — fails startup instead of picking one. Replace the chain rather than cancelling it. - Expecting binary frames to be sanitized. A
Bufferpayload reaches@OnMessageunprocessed; validate it yourself. - Assuming a failed
@OnMessagecloses the connection. It does not; the failure is routed to@OnErrorand the socket stays open. Close it yourself if that is the intent. - Sending from
@OnOpenand expecting inbound frames first. Outbound writes work immediately; inbound delivery starts only after@OnOpen’s future completes. - Using a
@PathParamtype other thanString,int/Integer, orlong/Long. This passes startup validation and fails per-invocation. - Adding a Vert.x
AuthorizationProviderwithout installingVertxAuthorizationImportModule. A contributed provider is inert on its own — role and scope decisions are evaluated from the framework’sSecurityContextclaims. When the application includes the opt-inVertxAuthorizationImportModule(fromdev.vertique:vertique-rest-security), the security-ownedIdentityPipelineFactorythreads the importer into the handler this module installs, so contributed providers change authorization outcomes at upgrade time.WebSocketModuleitself no longer declares the provider/importer bindings — they live entirely behindAuthModule’sIdentityPipelineFactory.
Dependencies
Section titled “Dependencies”| Dependency | Why |
|---|---|
dev.vertique:vertique-rest-core |
RouterMount, request-lifecycle handle, SecurityRuntime, RouteAuthHandler, the request-completion claim for a successful upgrade |
dev.vertique:vertique-input-processing |
the neutral InputObjectProcessor / EffectiveInputPolicies contracts message and path-parameter processing are typed against |
dev.vertique:vertique-json |
JacksonFieldNameResolver — the wire-name projection that lets a declared policy reach a renamed message field |
dev.vertique:vertique-rest-security |
Policy enforcement, identity resolution, claim mapping |
dev.vertique:vertique-core |
Context holder, config parsing, Bean Validation and sanitization contracts, including the InputFieldNameResolver projection contract message processing is typed against |
dev.vertique:vertique-context |
ContextSnapshot/ContextValues used to carry request context across the handshake |
dev.vertique:vertique-security-core |
SecurityContext, ChannelIdentityManager, action-gate types |
dev.vertique:vertique-security-runtime |
Security event emission for the connection lifecycle |
dev.vertique:vertique-logging |
Logging conventions |
io.vertx:vertx-web |
Router, routing context, ServerWebSocket |
io.vertx:vertx-auth-common |
Vert.x authorization provider types on the security seam |
jakarta.ws.rs:jakarta.ws.rs-api |
@PathParam and the JAX-RS security annotations |
com.fasterxml.jackson.core:jackson-databind |
Typed message serialization |
com.google.dagger:dagger |
Module and multibinding declarations |
org.projectlombok:lombok |
Compile-time only |