Skip to content

fix(auth): advertise supports_auth_refresh and support rotating tokens - #429

Open
david-streamlio wants to merge 1 commit into
streamnative:masterfrom
david-streamlio:fix/auth-refresh-token-expiry
Open

david-streamlio wants to merge 1 commit into
streamnative:masterfrom
david-streamlio:fix/auth-refresh-token-expiry

Conversation

@david-streamlio

Copy link
Copy Markdown

Fixes #423.

Root cause A — the disconnects

messages::connect never populated CommandConnect.feature_flags. The field exists in
PulsarApi.proto, but grep -rn "feature_flags" src/ returned nothing.

The broker gates auth refresh on exactly that flag — ServerCnx.refreshAuthenticationCredentials():

if (!supportsAuthenticationRefresh()) {
    log.warn("[{}] Closing connection because client doesn't support auth credentials refresh", remoteAddress);
    ctx.close();
    return;
}
... writeAndFlush(Commands.newAuthChallenge(authMethod, brokerData, getRemoteEndpointProtocolVersion()));

supportsAuthenticationRefresh() is features != null && features.isSupportsAuthRefresh(),
and features is copied from CommandConnect.feature_flags in handleConnect. The Java
client sets setSupportsAuthRefresh(true) in Commands.newConnect; this client did not.

So the broker took the ctx.close() branch on every token expiry and never sent a
CommandAuthChallenge — which is why the reporter never saw Received AuthChallenge
while the Python client did.

Consequence: the entire AuthChallenge path already in connection.rs (the Receiver
arm, the auth_challenge_rx refresh task, messages::auth_challenge) was dead code
against a real broker. connection_auth_challenge_test passed only because the fake
server sent the challenge unprompted.

Root cause B — the failure to recover

with_auth(Authentication { name, data }) wraps a Vec<u8> snapshot; its auth_data()
returns the same bytes forever, as did TokenAuthentication. So even with the challenge
working, the response would carry the expired token — and on reconnection the same stale
token produced a server-side AuthenticationError, which establish_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.rs

  • messages::connect advertises feature_flags.supports_auth_refresh = true.
    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.
  • messages::auth_challenge now sets client_version and protocol_version, which the
    broker reads off the auth response and feeds into doAuthentication.
  • Added messages::PROTOCOL_VERSION so the connect frame and the auth response cannot
    report 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 a TokenSource enum:

  • from_file(path) re-reads and trims the token on every request. This is the Kubernetes
    projected ServiceAccount case, where a token is rotated in place before it expires.
    Read errors map to AuthenticationError::Retriable, so a file briefly absent
    mid-rotation retries instead of failing hard.
  • from_supplier(f) for tokens sourced some other way.
  • new() now returns Box<dyn Authentication> instead of Rc<dyn Authentication>.
    A signature change, but not a usable break: the Rc form is !Send and could never be
    passed to with_auth_provider, and it had no call sites in the crate or examples.

src/client.rs — with_auth / with_auth_provider docs now state which one survives
an expiring token, with a from_file example.

Tests

TestAuthentication 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 connect frame carries test_auth_data-0 while
the challenge response carries -1.

Added coverage for the connect feature flag, the auth-response version fields, and the
three TokenAuthentication constructors.

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 failed
  • cargo fmt --all --check — clean
  • Both CI clippy feature sets — 0 new findings

CI will be red until #427 merges

master currently carries 15 pre-existing useless_borrows_in_formatting errors under
Rust 1.98.0, unrelated to this change — see #426, fixed by #427. I verified the count is
identical (15) on unmodified master and on this branch, with zero attributable to
these commits. Merging #427 first and rebasing this PR will make it green.

Not addressed

Making a server-side AuthenticationError retryable at connect time. With the above in
place 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

…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
@david-streamlio

Copy link
Copy Markdown
Author

@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 CommandConnect.feature_flags, so ServerCnx.supportsAuthenticationRefresh() was false and the broker closed the connection on token expiry instead of sending a CommandAuthChallenge. Side effect worth noting for reviewers: the existing AuthChallenge handling in connection.rs has therefore been dead code against a real broker, and connection_auth_challenge_test only passed because the fake server sent the challenge unprompted.

Two things I'd especially like a second opinion on:

  1. TokenAuthentication::new return type — changed from Rc<dyn Authentication> to Box<dyn Authentication>. I read this as a non-breaking signature change since Rc is !Send and so could never be passed to with_auth_provider, and there are no call sites in the crate or examples. Please sanity-check that reasoning.
  2. Whether a server-side AuthenticationError should be retryable at connect time — I deliberately left this alone (rationale in the PR description). Maintainer call.

CI will be red until #427 lands — those 15 lint errors are pre-existing on master (#426), and I confirmed the count is identical with and without these commits, none attributable here. Happy to rebase once #427 merges.

I have no broker with short-TTL tokens to hand, so the end-to-end confirmation in the PR description is untested.

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.

Client never advertises supports_auth_refresh, so tokens are not rotated and connections drop at expiry

1 participant