Skip to content

Authorize the SSE stream with short-lived project-scoped tickets - #49

Merged
johardi merged 9 commits into
epic/303-ssefrom
feat/305-sse-auth
Jul 27, 2026
Merged

Authorize the SSE stream with short-lived project-scoped tickets#49
johardi merged 9 commits into
epic/303-ssefrom
feat/305-sse-auth

Conversation

@johardi

@johardi johardi commented Jul 25, 2026

Copy link
Copy Markdown
Member

protegeproject/webprotege-gwt-ui#305 (part of protegeproject/webprotege-gwt-ui#303). Stacked on #48.

EventSource cannot send an Authorization header, so the stream is guarded by an opaque, single-project, short-TTL ticket instead:

  • POST /data/events/ticket (bearer-authenticated) issues {"ticket","expiresIn"} after a ViewProject check; identity comes from the validated JWT, never the request body.
  • The stream endpoint redeems the ticket on every connect — including each native auto-reconnect — re-running the permission check with the server-side recorded identity. 401 for missing/expired/wrong-project tickets, 403 once access is revoked. Ticket values never appear in application logs.
  • The store sits behind an interface for #307 to swap in a shared/stateless impl for multi-instance.

Deliberately rejected (documented in the brief): cookie auth (wrong cookie domain/path, session state in the gateway, CSRF re-exposure) and the raw access token in the URL (5-minute Keycloak tokens in access logs). Residual: tickets do appear in nginx access logs, bounded by the 120s TTL + project scope + re-check on redemption.

Tests: 66/66 (+21 for issuance/store/redemption, JUnit 5, JDK 17). The client half (mint ticket → open stream → re-mint on fatal error) lands with #306.

johardi added 9 commits July 24, 2026 20:56
Viewers can now open GET /data/projects/{projectId}/events as a
server-sent-events stream. A registry keeps the open streams per project
and fans incoming payloads out on an executor, evicting dead emitters on
send failure; a scheduled comment heartbeat keeps idle streams alive
through nginx's 60-second proxy read timeout, and the path rides the
existing /data/ route so no proxy changes are needed (response buffering
is disabled per-response with X-Accel-Buffering).

Authorization at stream-open mirrors the STOMP subscribe check: the
caller must hold ViewProject on the project, with identity resolution
isolated in one seam that #305 replaces with a short-lived redeemable
ticket. The Last-Event-ID header (and a lastEventId query fallback for
fresh loads, which never send the header) is plumbed to a catch-up seam
whose real replay implementation arrives with the sequence work; streams
also get a finite lifetime so every reconnect re-authorizes.

Nothing publishes into the registry yet — the broker bridge lands
separately once events carry real sequence numbers (#296).

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
…tags

Every pushed event window carried the hardcoded tags 0 to 1, so after the
first window advanced a client's bookmark past 1, the client's stale-
window guard dropped every subsequent push — the websocket went silent
after one event and delivery quietly fell back to the 10-second poll.
That is the direct cause of the ~9.5s median UI latency measured in the
epic's baseline.

The emitter now consumes the sequenced event that the event-history
service publishes after archiving a change bundle, and pushes it with
truthful tags: startTag is the previous ordinal, endTag is the bundle's
ordinal. Because that event is emitted strictly after persistence,
anything pushed is already fetchable through the pull path. The wire
payload shape is unchanged, so the client deserializes it exactly as
before. The error log now includes the exception so failures are
diagnosable.

The gateway half of protegeproject/webprotege-gwt-ui#296
(part of protegeproject/webprotege-gwt-ui#303).
The sequenced change events the gateway consumes now fan out to the open
SSE streams as well as the STOMP topic, carrying the per-project
sequence ordinal as the SSE event id and the same truthful tags in the
payload. Both sinks send the identical payload object, so the bytes a
client sees are the same regardless of transport, and the registry hands
each emitter write to its dispatch executor so a slow stream can never
stall the broker listener thread. STOMP delivery stays in place until
the old transport is retired.

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
A stream that opens with a last-seen event id now gets exact catch-up:
the gateway queries the durable event archive for everything after that
id, sends the result as a single replay frame whose id is the archive's
end position, and only then releases live delivery. Live events arriving
during the replay are buffered per subscriber and flushed in order with
anything at or below the replay position dropped, so nothing is missed
and nothing is delivered twice regardless of how the broker and the
query interleave. The buffer is bounded; on overflow or a failed history
query the stream stays alive and falls back to live-only, where the
client's gap detection recovers the difference.

Fresh connections carry no id and start at now — project open downloads
no history on this transport either.

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
The SSE stream cannot carry a bearer header, so authorization needs a
credential that can safely ride a URL. An authenticated endpoint now
issues an opaque, single-project stream ticket: identity comes from the
validated bearer token — never from the request body — the caller's view
permission is checked before issuance, and the ticket expires after a
short configurable window. Tickets live in a store behind an interface
so a multi-instance deployment can swap the in-memory implementation for
a shared one. Ticket values are never written to logs.

Part of protegeproject/webprotege-gwt-ui#305
(epic protegeproject/webprotege-gwt-ui#303).
…eam opens

The stream endpoint's interim identity-from-query-params check is
replaced by ticket redemption: the connection is refused without a
live ticket for exactly the requested project, and the view-permission
check re-runs on every connect with the identity recorded server-side
at issuance — so each of the browser's automatic reconnects is a fresh
authorization checkpoint, and revoked access takes effect at the next
reconnect instead of never. Nothing the request itself carries is
trusted for authorization.

Part of protegeproject/webprotege-gwt-ui#305
(epic protegeproject/webprotege-gwt-ui#303).
Delivering a change bundle failed at the gateway with a Jackson error:
the sequenced event and the history-query response typed their inner
events against this service's own event model, and the event-history
service's serialization of a hierarchy-changed event does not construct
under it. The gateway never acts on the inner events — it only relays
them to browsers — so the event list and the sequenced bundle now carry
them as raw JSON. What reaches the client is byte-for-byte what the
producer serialized, and the gateway stops breaking whenever its copy of
the event model drifts from a producer's.

Found running the end-to-end suite against the epic stack: viewers
opened streams fine but never received a single event.

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
Keep-alives went out as SSE comment frames, which browsers never deliver
to page JavaScript — so the client had no way to tell a quiet-but-healthy
stream from a half-open zombie connection that a network drop killed
without an error. The heartbeat is now a real named event with an empty
data field (a data-less event would also never be dispatched), and the
client uses it to detect and reopen silent streams.

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
A client cannot tell from a bare heartbeat whether it has actually seen
every event — a frame dropped by the browser across a brief network loss
leaves the connection healthy and the loss invisible until the next
event happens to arrive. Heartbeats now carry the highest sequence this
instance has published for the project, giving clients a bounded-time
way to notice they are behind and pull the difference.

Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
@johardi
johardi changed the base branch from feat/304-sse-bridge to epic/303-sse July 27, 2026 19:19
@johardi
johardi merged commit f0e9240 into epic/303-sse Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant