Summary
A consumer stream JWT is verified by audience only, with no tenancy binding, so any aud=stream token minted by the auth service opens the stream on any operator's gateway, not just the one it was issued for.
Called out during review of #68 but not fixed there. Pre-existing: it arrived with the auth seam (#61) and the WS transport (#65), and #68 extends the same surface to a second port.
What the auth service already sends
optimum-auth mints operator_id and cluster_ids onto stream tokens specifically as the tenancy binding (src/lib/es256.ts, mintStreamToken). Its own commit message is explicit that the enforcement half is missing:
operator_id + cluster_ids are the tenancy binding: the gateway verifies locally against the JWKS, so without them any operator's token opens any operator's stream.
and:
without any tenancy binding any Optimum-minted stream token would validate against every operator's gateway, since the gateway checks only issuer and aud. Emitting them now lets the gateway enforce whenever it lands the check.
So the claim is on the wire today. Nothing consumes it.
What the gateway does
jwks_verifier.Claims has no operator_id field at all. ClusterIDs exists, but is documented as enforced at the handshake, not on the stream path.
VerifyStreamToken (pkg/service/auth_token/manager.go:284) calls verifier.Verify(rawJWT, AudStream) and returns. Deliberately no chain gate, and no tenancy gate either.
pkg/service/stream never reads operator_id or cluster_ids. jwksAuthenticator.Authenticate returns claims.Subject, used only as the connection-cap key.
Verify pins iss (the configured OPT_REMOTE_AUTH_URL) and aud. Every gateway trusting that issuer shares the same JWKS, so a token issued for operator A validates on operator B's gateway.
Impact
Any holder of a valid stream token can read the beacon-block stream of any gateway that trusts the same issuer, including mode=raw with verbatim block bytes. Since the operator hands these tokens to their own downstream consumers, one operator's consumer can read another operator's stream if they can reach the port.
The connection caps do not mitigate this: they key on sub, so a foreign token simply occupies its own slot.
ADR-0011's "No scope claim in v1" line is about topics ("there is one topic, so a valid stream token grants read of the whole stream"), not about which gateway a token is valid against, so this does not look like an intentional v1 simplification.
Possible shape of a fix
Gateway-side only, since the claim is already emitted:
- Add
OperatorID string \json:"operator_id"`tojwks_verifier.Claims`.
- Gate it on the stream path, either in
VerifyStreamToken or in stream.jwksAuthenticator, comparing against the gateway's own operator. auth_token.Service.OperatorID() already exposes that, sourced from the /auth/token mint response.
- Decide the behaviour when the gateway's own
operator_id is empty. It is documented as empty when upstream auth predates the field, so failing closed would break those deployments and failing open would silently restore today's behaviour. Whichever is chosen should be explicit rather than incidental.
cluster_ids could gate further if stream access should be cluster-scoped, following the handshake precedent.
Worth a test that a token minted for a different operator is rejected, on both transports.
Summary
A consumer stream JWT is verified by audience only, with no tenancy binding, so any
aud=streamtoken minted by the auth service opens the stream on any operator's gateway, not just the one it was issued for.Called out during review of #68 but not fixed there. Pre-existing: it arrived with the auth seam (#61) and the WS transport (#65), and #68 extends the same surface to a second port.
What the auth service already sends
optimum-auth mints
operator_idandcluster_idsonto stream tokens specifically as the tenancy binding (src/lib/es256.ts,mintStreamToken). Its own commit message is explicit that the enforcement half is missing:and:
So the claim is on the wire today. Nothing consumes it.
What the gateway does
jwks_verifier.Claimshas nooperator_idfield at all.ClusterIDsexists, but is documented as enforced at the handshake, not on the stream path.VerifyStreamToken(pkg/service/auth_token/manager.go:284) callsverifier.Verify(rawJWT, AudStream)and returns. Deliberately no chain gate, and no tenancy gate either.pkg/service/streamnever readsoperator_idorcluster_ids.jwksAuthenticator.Authenticatereturnsclaims.Subject, used only as the connection-cap key.Verifypinsiss(the configuredOPT_REMOTE_AUTH_URL) andaud. Every gateway trusting that issuer shares the same JWKS, so a token issued for operator A validates on operator B's gateway.Impact
Any holder of a valid stream token can read the beacon-block stream of any gateway that trusts the same issuer, including
mode=rawwith verbatim block bytes. Since the operator hands these tokens to their own downstream consumers, one operator's consumer can read another operator's stream if they can reach the port.The connection caps do not mitigate this: they key on
sub, so a foreign token simply occupies its own slot.ADR-0011's "No scope claim in v1" line is about topics ("there is one topic, so a valid stream token grants read of the whole stream"), not about which gateway a token is valid against, so this does not look like an intentional v1 simplification.
Possible shape of a fix
Gateway-side only, since the claim is already emitted:
OperatorID string \json:"operator_id"`tojwks_verifier.Claims`.VerifyStreamTokenor instream.jwksAuthenticator, comparing against the gateway's own operator.auth_token.Service.OperatorID()already exposes that, sourced from the/auth/tokenmint response.operator_idis empty. It is documented as empty when upstream auth predates the field, so failing closed would break those deployments and failing open would silently restore today's behaviour. Whichever is chosen should be explicit rather than incidental.cluster_idscould gate further if stream access should be cluster-scoped, following the handshake precedent.Worth a test that a token minted for a different operator is rejected, on both transports.