Repository navigation
fix(auth): advertise supports_auth_refresh and support rotating tokens - #429
david-streamlio wants to merge 1 commit into
Conversation
…ction
The client never populated CommandConnect.feature_flags, so the broker's
ServerCnx.supportsAuthenticationRefresh() returned false and it closed the
connection outright when credentials expired ("client doesn't support auth
credentials refresh") rather than sending a CommandAuthChallenge. The whole
AuthChallenge path already in this file was therefore dead code against a real
broker, which is why reporters never saw "Received AuthChallenge".
Advertise supports_auth_refresh so the broker issues the challenge.
supports_broker_entry_metadata and supports_partial_producer stay false: this
client implements neither, and advertising them would change the payload the
broker sends back.
Also fill in CommandAuthResponse.client_version and .protocol_version, which
the broker reads and feeds into doAuthentication, and hoist the advertised
protocol version into a shared constant so the connect frame and the auth
response cannot drift apart. The value itself is unchanged at 12.
Fixing the challenge only helps if the credentials can actually change, and
neither with_auth() nor TokenAuthentication could produce a fresh token — so
after the broker closed the connection, reconnection replayed the expired token
and failed permanently with a non-retryable AuthenticationError. Add
TokenAuthentication::from_file, which re-reads and trims the token on every
request (the Kubernetes projected ServiceAccount case, where a token is rotated
in place before it expires), and TokenAuthentication::from_supplier for tokens
sourced some other way. Read errors map to AuthenticationError::Retriable so a
file that is briefly absent mid-rotation retries rather than failing hard.
TokenAuthentication::new now returns Box<dyn Authentication> instead of
Rc<dyn Authentication>. This is a signature change but not a usable break: the
Rc form is !Send and so could never be passed to with_auth_provider, and it had
no call sites in the crate or its examples.
The existing test provider returned a constant token, which cannot distinguish
a refresh that re-read its credentials from one that replayed a stale token; it
now hands out a distinct token per call and the test asserts the challenge
response carries the second one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016225V4xL2DjNvgKMDQcu7Y
|
@BewareMyPower @darinspivey @codelipenghui — review would be welcome here. I don't have permission to request reviewers on this repo, so flagging it in a comment. This fixes #423 (reported against 6.7.2, confirmed unchanged at 6.8.0): the client never set Two things I'd especially like a second opinion on:
CI will be red until #427 lands — those 15 lint errors are pre-existing on I have no broker with short-TTL tokens to hand, so the end-to-end confirmation in the PR description is untested. |
Fixes #423.
Root cause A — the disconnects
messages::connectnever populatedCommandConnect.feature_flags. The field exists inPulsarApi.proto, butgrep -rn "feature_flags" src/returned nothing.The broker gates auth refresh on exactly that flag —
ServerCnx.refreshAuthenticationCredentials():supportsAuthenticationRefresh()isfeatures != null && features.isSupportsAuthRefresh(),and
featuresis copied fromCommandConnect.feature_flagsinhandleConnect. The Javaclient sets
setSupportsAuthRefresh(true)inCommands.newConnect; this client did not.So the broker took the
ctx.close()branch on every token expiry and never sent aCommandAuthChallenge— which is why the reporter never sawReceived AuthChallengewhile the Python client did.
Consequence: the entire AuthChallenge path already in
connection.rs(theReceiverarm, the
auth_challenge_rxrefresh task,messages::auth_challenge) was dead codeagainst a real broker.
connection_auth_challenge_testpassed only because the fakeserver sent the challenge unprompted.
Root cause B — the failure to recover
with_auth(Authentication { name, data })wraps aVec<u8>snapshot; itsauth_data()returns the same bytes forever, as did
TokenAuthentication. So even with the challengeworking, the response would carry the expired token — and on reconnection the same stale
token produced a server-side
AuthenticationError, whichestablish_retryable()classifies as fatal, wedging the client permanently.
This is why the reporter's custom file-reading provider restored recovery but did not
change the disconnect count: it addressed B, not A.
Changes
src/connection.rsmessages::connectadvertisesfeature_flags.supports_auth_refresh = true.supports_broker_entry_metadataandsupports_partial_producerstayfalse: thisclient implements neither, and advertising them would change the payload the broker
sends back.
messages::auth_challengenow setsclient_versionandprotocol_version, which thebroker reads off the auth response and feeds into
doAuthentication.messages::PROTOCOL_VERSIONso the connect frame and the auth response cannotreport different versions. The value is unchanged at 12 — bumping it pulls in
broker-entry-metadata expectations and belongs in its own change.
src/authentication/token.rs— rewritten around aTokenSourceenum:from_file(path)re-reads and trims the token on every request. This is the Kubernetesprojected ServiceAccount case, where a token is rotated in place before it expires.
Read errors map to
AuthenticationError::Retriable, so a file briefly absentmid-rotation retries instead of failing hard.
from_supplier(f)for tokens sourced some other way.new()now returnsBox<dyn Authentication>instead ofRc<dyn Authentication>.A signature change, but not a usable break: the
Rcform is!Sendand could never bepassed to
with_auth_provider, and it had no call sites in the crate or examples.src/client.rs—with_auth/with_auth_providerdocs now state which one survivesan expiring token, with a
from_fileexample.Tests
TestAuthenticationreturned a constant token, which cannot distinguish a refresh thatre-read its credentials from one that replayed a stale token. It now hands out a distinct
token per call, and the test asserts the connect frame carries
test_auth_data-0whilethe challenge response carries
-1.Added coverage for the connect feature flag, the auth-response version fields, and the
three
TokenAuthenticationconstructors.Negative check: reverting the feature flag and the auth-response version fields makes
exactly the three new/updated tests fail, so none of them are vacuous.
Verification
Run locally on rustc 1.98.0:
cargo test --features tokio-runtime --lib— 44 passed, 0 failed (38 before)cargo test --features tokio-runtime --doc— 20 passed, 0 failedcargo fmt --all --check— cleanCI will be red until #427 merges
mastercurrently carries 15 pre-existinguseless_borrows_in_formattingerrors underRust 1.98.0, unrelated to this change — see #426, fixed by #427. I verified the count is
identical (15) on unmodified
masterand on this branch, with zero attributable tothese commits. Merging #427 first and rebasing this PR will make it green.
Not addressed
Making a server-side
AuthenticationErrorretryable at connect time. With the above inplace the reported failure no longer depends on it, and it would delay surfacing a
genuinely bad credential by ~5 minutes of backoff. Flagging it as a maintainer call.
Not verifiable here
End-to-end confirmation against a live broker — that it logs "Refreshing authentication
credentials" rather than "client doesn't support auth credentials refresh", that the
client logs
Received AuthChallenge, and that the reporter's 45-minute loop stays up.That needs a broker issuing short-TTL tokens.
🤖 Generated with Claude Code
https://claude.ai/code/session_016225V4xL2DjNvgKMDQcu7Y