We ran into this while paging through /v2/state/active-contracts over WebSocket, as recommended in the docs (https://docs.canton.network/global-synchronizer/reference/api-configuration#websockets) for result sets too large for the non-streaming endpoint.
What happens
Connecting a WebSocket client to /v2/state/active-contracts with subprotocols daml.ws.auth and jwt.token.<TOKEN> (the documented auth mechanism for this endpoint), e.g.:
const ws = new WebSocket(
`wss://<host>/v2/state/active-contracts`,
['daml.ws.auth', `jwt.token.${token}`]
);
or via wscat:
wscat -c "wss://<host>/v2/state/active-contracts" -s "daml.ws.auth" -s "jwt.token.<TOKEN>"
The handshake succeeds fine (HTTP/1.1 101 Switching Protocols, Sec-WebSocket-Protocol: daml.ws.auth correctly echoed back). But as soon as we send the request body, the first response frame is:
{"code":"NA","cause":"A security-sensitive error has been received","correlationId":"...","traceId":"...","context":{},"resources":[],"errorCategory":-1,"grpcCodeValue":16,"retryInfo":null,"definiteAnswer":null}
grpcCodeValue: 16 is UNAUTHENTICATED — even though the exact same JWT works fine via the standard Authorization: Bearer header on non-WS endpoints.
Why
The JWT extraction for WebSocket connections lives in Endpoints.scala (community/ledger/ledger-json-api/.../http/json/v2/Endpoints.scala):
auth
.apiKey(header[Option[String]]("Sec-WebSocket-Protocol"))
.map { bearer =>
val tokenPrefix = "jwt.token." // TODO (i21030) test this
bearer
.map(_.split(",").toSeq)
.getOrElse(Seq.empty)
.filter(_.startsWith(tokenPrefix))
.map(_.substring(tokenPrefix.length))
.headOption
.map(Jwt.apply)
}(_.map(_.token))
It splits Sec-WebSocket-Protocol on , but never trims the resulting strings. RFC 6455 clients (browsers, ws, wscat) send this header as a comma-space separated list, so "daml.ws.auth, jwt.token.<TOKEN>".split(",") gives ["daml.ws.auth", " jwt.token.<TOKEN>"] — note the leading space on the second element. .startsWith("jwt.token.") then fails, the filter drops it, and .headOption returns None. The JWT is silently dropped, the request goes out unauthenticated, and the ledger API rejects it.
The pre-existing // TODO (i21030) test this comment suggests this path never actually had a passing end-to-end test with a real client, which is presumably how this slipped through.
Fix
Opened #613 with the fix (trims each element after splitting) and regression tests.
Originally raised against canton-network/splice#6776
We ran into this while paging through
/v2/state/active-contractsover WebSocket, as recommended in the docs (https://docs.canton.network/global-synchronizer/reference/api-configuration#websockets) for result sets too large for the non-streaming endpoint.What happens
Connecting a WebSocket client to
/v2/state/active-contractswith subprotocolsdaml.ws.authandjwt.token.<TOKEN>(the documented auth mechanism for this endpoint), e.g.:or via
wscat:The handshake succeeds fine (
HTTP/1.1 101 Switching Protocols,Sec-WebSocket-Protocol: daml.ws.authcorrectly echoed back). But as soon as we send the request body, the first response frame is:{"code":"NA","cause":"A security-sensitive error has been received","correlationId":"...","traceId":"...","context":{},"resources":[],"errorCategory":-1,"grpcCodeValue":16,"retryInfo":null,"definiteAnswer":null}grpcCodeValue: 16isUNAUTHENTICATED— even though the exact same JWT works fine via the standardAuthorization: Bearerheader on non-WS endpoints.Why
The JWT extraction for WebSocket connections lives in
Endpoints.scala(community/ledger/ledger-json-api/.../http/json/v2/Endpoints.scala):It splits
Sec-WebSocket-Protocolon,but never trims the resulting strings. RFC 6455 clients (browsers,ws,wscat) send this header as a comma-space separated list, so"daml.ws.auth, jwt.token.<TOKEN>".split(",")gives["daml.ws.auth", " jwt.token.<TOKEN>"]— note the leading space on the second element..startsWith("jwt.token.")then fails, the filter drops it, and.headOptionreturnsNone. The JWT is silently dropped, the request goes out unauthenticated, and the ledger API rejects it.The pre-existing
// TODO (i21030) test thiscomment suggests this path never actually had a passing end-to-end test with a real client, which is presumably how this slipped through.Fix
Opened #613 with the fix (trims each element after splitting) and regression tests.
Originally raised against canton-network/splice#6776