feat(mqtt5): implement re-authentication and handle auth success#2048
feat(mqtt5): implement re-authentication and handle auth success#2048abdelrahmanSheref101 wants to merge 4 commits intomqttjs:mainfrom
Conversation
- Added public `reauthenticate` method to MqttClient for MQTT 5.0. - Updated `src/lib/handlers/auth.ts` to handle Reason Code 0x00 (Success). - Added logic to distinguish between initial authentication success and re-authentication. - Added 'reauth' event to MqttClientEventCallbacks. - Added comprehensive tests in test/client_mqtt5.ts covering successful MQTT 5.0 re-authentication handshakes, protocol version enforcement (v5 only), and validation for mandatory authentication methods.
robertsLando
left a comment
There was a problem hiding this comment.
Code Review
Thanks for tackling #1795! The direction is good and the surface is small. Below are correctness, API-design, and test issues to address before merging.
Correctness
1. _sendPacket callback semantics don't match an "operation completed" callback
src/lib/client.ts line 1740 calls this._sendPacket(authPacket, callback). _sendPacket's callback fires once the packet has been written to the wire — not when the broker replies. Users almost certainly expect client.reauthenticate(opts, cb) to fire cb when the AUTH exchange completes (similar to how publish/subscribe callbacks correlate with their ack). As written, cb fires immediately and the user must also subscribe to 'reauth' to learn the result. Pick one model — I'd suggest the callback receives (err, responsePacket) correlated by the handler.
2. Double signaling on validation errors
if (callback) callback(error)
this.emit('error', error)
return thisBoth the callback and 'error' are fired for the same failure. Other public methods (publish, subscribe) call back with the error and do not re-emit 'error', since unhandled 'error' crashes the process. Surprising for users who pass a callback and don't have an 'error' listener. Pick one path.
3. Handler rc === 0 only emits when client.connected
} else if (rc === 0) {
if (client.connected) {
client.emit('reauth', packet)
}
}If rc === 0 arrives while not connected (early frame, malformed broker), it is now silently swallowed. Either drop the guard (the spec only allows AUTH rc=0 while connected anyway) or emit an explicit protocol error.
Also: the handler still calls client.handleAuth(packet, ...) which is meant for the application to compute a continue-auth response. On a reauth Success, the user callback runs and any returned packet2 is silently discarded — either document or skip for rc=0.
4. Debug log compares Buffers by reference
if (reAuthOptions.authenticationData === this.options.properties.authenticationData) {
this.log('reauthenticate: sending same authenticationData as initial connection')
}authenticationData is a Buffer — === will essentially never match. Use .equals() or remove the log; it doesn't pull weight as-is.
5. properties spread can let callers override authenticationMethod
properties: {
authenticationMethod: method,
...reAuthOptions,
}Today the Pick<> type excludes authenticationMethod, so this is safe at the type level — but JS callers or as any can still override it, silently violating the spec (the method must match the initial CONNECT). Move authenticationMethod: method after the spread for runtime defense.
API / style
- JSDoc is minimal. Compare to surrounding methods (
publish,subscribe,end). Please documentreAuthOptions, the'reauth'event, and the three error conditions. - Naming:
reAuthOptionsis inconsistent with the codebase — preferreauthOptionsoroptions. - Magic number: Use the named constant from
mqtt-packetif one exists, or add0x19 // Re-authenticateas an inline comment. - Discoverability: the new
reauth: OnPacketCallbackevent should be mentioned in README/typings.
Tests (test/node/client_mqtt5.ts)
- Port offsets
+90/+91/+92reuse the samePORTAND103base as many surrounding tests. Real risk of collision — the convention here is to monotonically extend the offset, not jump backwards. - Success-path test never asserts the broker received the expected AUTH — add
assert.strictEqual(packet.reasonCode, 0x19)and verifypacket.properties.authenticationDataserver-side. - No callback test. Given the ambiguity in (1), decide the semantics and lock them in with a test.
- No "not connected" test. The
!this.connectedguard is uncovered. - The non-v5 test uses
port: port— use object shorthand to match surrounding style. - The non-v5 test has a
try/catcharound the assertion but the other two don't — make them consistent (preferably without the try/catch; the test runner reports throws fine).
Risk summary
| Area | Risk |
|---|---|
| Correctness | Medium — rc=0 handling is incomplete; double error signaling |
| API | Medium — callback semantics are misleading |
| Tests | Medium — happy-path test doesn't actually validate the wire format |
| Backwards compatibility | Low — purely additive |
| Security | Low — but the Buffer-equality bug should be fixed before anyone relies on it |
Suggested next steps
- Decide and document
callbacksemantics; remove the double-emit on validation errors. - Drop the
client.connectedguard in the handler (or convert it to an explicit protocol error). - Strengthen the success-path test to assert the AUTH packet contents server-side.
- Add a "callback receives error" test and a "not connected" test.
- Pick a non-colliding port offset.
- Flesh out JSDoc and the
'reauth'event documentation. - Replace
===Buffer comparison with.equals()or remove the log.
…w feedback This commit finalizes the re-authentication feature, ensuring full alignment with the MQTT 5.0 spec and project standards as per mqttjs#1795 review. Correctness & Logic: - Fix callback semantics: The callback now waits for the broker's AUTH response instead of firing when the packet is written to the wire. - Remove double-signaling: Validation errors now trigger the callback OR the error event, preventing redundant crashes. - Fix Buffer comparison: Replaced '===' with '.equals()' for authenticationData validation in debug logs. - Spec compliance: Ensured authenticationMethod cannot be overridden by spread properties during re-auth. Documentation: - Added comprehensive JSDoc for reauthenticate() and the 'reauth' event. - Updated README.md with event descriptions and method signatures for better feature discoverability. - Renamed reAuthOptions to reauthOptions for consistency. Testing: - Updated port offsets to avoid collisions with existing MQTT 5.0 tests. - Strengthened success-path tests with server-side assertions of AUTH packets. - Added coverage for 'not connected', 'non-v5', and 'missing auth method' errors. - Added a 'no callback' test to ensure stability when the callback is omitted. Ref: mqttjs#1795
|
Hi @robertsLando , Addressed all review feedback:
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2048 +/- ##
==========================================
- Coverage 83.42% 83.39% -0.04%
==========================================
Files 25 25
Lines 1587 1626 +39
Branches 367 380 +13
==========================================
+ Hits 1324 1356 +32
- Misses 230 235 +5
- Partials 33 35 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
robertsLando
left a comment
There was a problem hiding this comment.
See inline comments. Main concerns: the handler dispatch should branch on client.connected rather than on the reason code, _handleReauthCompleted leaks as a public API symbol, and there is a silent-error path when the client is torn down mid-reauth without a callback. A few smaller cleanups and missing test coverage noted inline.
- Refactor auth handler to branch on connection state instead of reason codes - Flatten reauthenticate validation logic using short-circuiting pattern - Make internal re-auth completion method private and renamed for clarity - Fix JSDoc syntax errors and improve documentation - Remove debug logs in favor of proper internal handling - Add test coverage for failure reason codes and request interruption
|
Hi @robertsLando, First, I want to apologize for the initial messiness and the 'hustle' in the previous push. I really appreciate you taking the time to provide such detailed mentorship through your review. It means a lot to have a dev like you look over this. I have addressed all your requested changes:
Ready for another look when you have a moment. Thanks again for your patience! |
robertsLando
left a comment
There was a problem hiding this comment.
Thanks for the changes! Much better now :) Considering we have a promised version of all methods I think it could make sense to also have an async version of reauthenticate
- Implement reauthenticateAsync wrapper for Promise-based usage
|
Hi @robertsLando , |
robertsLando
left a comment
There was a problem hiding this comment.
In-depth review of the MQTT 5 re-authentication flow. Findings posted as separate inline threads below — grouped roughly by severity (protocol bugs first, then API/edge cases, then style/tests).
| } | ||
|
|
||
| if (client.connected) { | ||
| client['_handleReauth'](null, packet) |
There was a problem hiding this comment.
[High — protocol bug] Multi-step / broker-initiated AUTH is broken.
This branch routes every AUTH packet received while client.connected straight into _handleReauth(null, packet) as if it were a terminal completion. Per MQTT 5.0 §3.15, AUTH while connected can carry:
0x18Continue Authentication — broker is sending a challenge; the client must reply with another AUTH containing the response (SCRAM, GSSAPI, Kerberos…).0x19Re-Authenticate — broker-initiated re-auth; the client must respond with new credentials.0x00Success — exchange complete.
Downstream, _handleReauth converts any non-zero reason code into Error('Re-auth failed: …'), so a perfectly normal 0x18 challenge gets surfaced as a hard failure and the in-flight _reauthCallback is rejected. Worse, when the broker initiates re-auth and there is no _reauthCallback, _handleReauth falls through to this.emit('error', …) — a remote peer can crash a Node process that doesn't have an error listener attached.
The handler needs to dispatch by reason code:
switch (packet.reasonCode) {
case 0x00: return client._handleReauth(null, packet) // complete
case 0x18: return client.handleAuth(packet, …) // continue — let user respond
case 0x19: return client.handleAuth(packet, …) // broker-initiated
default: return client._handleReauth(new ErrorWithReasonCode(…), packet)
}This is the most important issue in the PR — it silently regresses the multi-step auth flow that the existing client.handleAuth hook was designed to support.
There was a problem hiding this comment.
I’ve refactored the auth.ts handler to use a switch on the reason codes to properly handle the multi-step handshake (0x18).
Regarding the 0x19 (Re-authenticate) flow: I noticed Table 3.15.2.1 Authenticate Reason Code lists 0x19 as 'Sent by: Client' only, and §4.12.1 describes it as a client-initiated action. Is there a specific scenario or broker behavior where the server initiates with 0x19, or should we handle a server-sent 0x19 as a protocol error?
| } | ||
|
|
||
| if (client.connected) { | ||
| client['_handleReauth'](null, packet) |
There was a problem hiding this comment.
[Style] Bracket-access to bypass private.
client['_handleReauth'](…) defeats TypeScript's visibility checks. Either:
- mark the method as package-internal (underscore prefix is fine, but drop
private), likehandleAuthalready is on the same client, or - expose a small
internalnamespace.
As written, every refactor of _handleReauth will silently skip type-checking at this call site.
| return this | ||
| } | ||
|
|
||
| if (this._reauthCallback) { |
There was a problem hiding this comment.
[High] The interrupt fires before the new request is validated.
if (this._reauthCallback) {
this._handleReauth(new Error('reauthenticate: interrupted by new reauthentication request'))
}
if (this.options.protocolVersion !== 5) return fail(...)
else if (!this.connected) return fail(...)
else if (!reauthOptions.authenticationData) return fail(...)A second call with invalid arguments (wrong protocol version, missing data, etc.) will still cancel a perfectly valid in-flight re-auth before its own validation runs. The first caller is rejected with interrupted even though the second call is rejected immediately and never sends a new AUTH packet — net result: a healthy re-auth gets killed by a bogus call.
Move the validation above the interrupt branch so that interruption only happens when the new request will actually go on the wire.
| if (this.options.protocolVersion !== 5) | ||
| return fail('this feature works only with mqtt-v5') | ||
| else if (!this.connected) return fail('client is not connected') | ||
| else if (!reauthOptions.authenticationData) |
There was a problem hiding this comment.
[Medium] Null-safety on reauthOptions.
reauthOptions.authenticationData is dereferenced without first checking that reauthOptions itself is non-null. client.reauthenticate(null) / client.reauthenticate(undefined) (an easy mistake, especially from JS callers) crashes with a TypeError instead of a clean validation error. Add an if (!reauthOptions) return fail('reauthOptions is required') at the top of the validation block.
| } | ||
|
|
||
| this._reauthCallback = callback | ||
| this._sendPacket(authPacket) |
There was a problem hiding this comment.
[Medium] _sendPacket is fire-and-forget — callback can hang forever.
Two related issues here:
- No error path on send.
_sendPacket(packet, cb)exists for a reason — if the underlying stream write fails, the user callback is never invoked and_reauthCallbackstays set until_cleanUpruns (i.e. until the client is destroyed). Use:this._sendPacket(authPacket, (err) => { if (err) this._handleReauth(err) })
- No timeout. A silent broker leaves
_reauthCallbackpending indefinitely. Other request/response paths in this client (subscribe,unsubscribe,publish-QoS>0) have inflight tracking and reconnection-aware retries; re-auth has neither. Add a timeout (configurable, default ~10–30s) and clear it in_handleReauth.
| private connackPacket: IConnackPacket | ||
|
|
||
| /* @type {PacketCallback} - callback receives IAuthPacket */ | ||
| private _reauthCallback: PacketCallback = null |
There was a problem hiding this comment.
[Style] Type and JSDoc nits on the field declaration.
/* @type {PacketCallback} - callback receives IAuthPacket */
private _reauthCallback: PacketCallback = null/* … */(single asterisk) is not parsed as JSDoc — this comment is invisible to tooling. Use/** … */if you want the annotation, or just delete it (the TS type already documents it).- The type should be
PacketCallback | null. It compiles today only becausestrict: falseintsconfig.json, but the project has been steadily tightening — this will trip the momentstrictNullChecksis enabled.
| if (err) { | ||
| reject(err) | ||
| } else { | ||
| resolve(packet as IAuthPacket) |
There was a problem hiding this comment.
[Style] as IAuthPacket cast hides a real type gap.
PacketCallback is typed against the generic Packet union, so packet here is Packet | undefined. Casting to IAuthPacket papers over the fact that _reauthCallback's signature should really be (err, packet?: IAuthPacket) => void. Tightening the field type (see the _reauthCallback thread) lets you drop the cast and keeps the Promise branch honest about resolving with undefined on error.
| /** | ||
| * reauthenticate - MQTT 5.0 Re-authentication | ||
| * @param {Object} reauthOptions - Re-authentication properties | ||
| * @param {Buffer} [reauthOptions.authenticationData] - Binary data for auth exchange |
There was a problem hiding this comment.
[Doc] JSDoc says authenticationData is optional, the code says it's required.
* @param {Buffer} [reauthOptions.authenticationData] - Binary data for auth exchangeThe square brackets in JSDoc denote an optional parameter, but reauthenticate immediately rejects calls without it (return fail('authenticationData is required')) and the README correctly lists it as required. Drop the brackets in both reauthenticate and reauthenticateAsync JSDoc blocks.
| } | ||
|
|
||
| if (this._reauthCallback) { | ||
| this._handleReauth(new Error('client disconnected')) |
There was a problem hiding this comment.
[Minor] Re-auth cleanup placement in _cleanUp.
The call is fine, but consider whether 'client disconnected' is the right message in the disconnecting / planned-end path — the user explicitly closed the client, so a 'client disconnected' callback error reads like an unexpected failure. A small refinement: pass a more specific error (or none, with a 'cancelled'-shaped result) when this.disconnecting is true.
| }, | ||
| ) | ||
|
|
||
| describe('reauthenticate', () => { |
There was a problem hiding this comment.
[Test gaps] This suite covers the happy path well but misses the cases that actually exercise the protocol bugs flagged in auth.ts / client.ts:
- No test for
0x18Continue Authentication multi-step flow — the broker should be able to reply with a challenge that the client forwards tohandleAuth, then send a follow-up0x00. As written, the current implementation would fail this test. - No test for broker-initiated re-auth (broker sends
AUTH 0x19first). Today this would emit'error'on the client. - No test for the
_sendPacketfailure path (e.g. write after stream end). - The interrupt test never asserts the second call completes. It only checks that the first callback receives
interrupted. Add an assertion that the second AUTH actually reaches the server and resolves. PORTAND327 + 82in the non-zero-reason-code test is an outlier — the rest of the suite uses+20..+26. Either consolidate or document why+82.- The
'should error if reauthenticate is called while disconnected'test races onconnecttiming. It's reliable today only becausemqtt.connectreturns synchronously withconnected=false; a future eager-connect refactor would silently flip the assertion. Consider hooking on a deterministic event instead. client.stream?.destroy?.()plustry {} catch {}workarounds in the auth-method test are symptomatic of the missing_sendPacketerror wiring — once that's fixed they should disappear.
Description
AUTHpackets within the client. This allows for dynamic credential updates or extended authentication exchanges without disconnecting the session.Key Changes:
.reauthenticate(options)method to trigger the re-auth flow.src/lib/handlers/auth.tsto correctly process Reason Code 0x00 (Success) for both initial handshakes and mid-session re-authentication.'reauth'event that emits upon a successful authentication exchange, providing the server's response packet to the user.authenticationMethod.Test Plan
I have added a new suite of tests in
test/client_mqtt5.tsto ensure stability:AUTHexchange using a mock broker, ensuring the'reauth'event triggers correctly.authenticationMethodwas defined during the initial connection.Local Results:
npm test: Passes (verified specifically for MQTT 5.0 client tests).