Skip to content

Fix SignalR JS: don't send Azure SignalR JWT to app server on reconnect#65638

Open
Copilot wants to merge 7 commits into
mainfrom
copilot/fix-signalr-jwt-issue
Open

Fix SignalR JS: don't send Azure SignalR JWT to app server on reconnect#65638
Copilot wants to merge 7 commits into
mainfrom
copilot/fix-signalr-jwt-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 3, 2026

When connecting through Azure SignalR Service, the negotiate redirect response returns an access token (Azure SignalR JWT) that gets stored in _httpClient._accessToken. On reconnect, _startInternal was resetting _accessTokenFactory and _httpClient._accessTokenFactory back to the original options values, but not _httpClient._accessToken — causing the stale Azure SignalR JWT to be sent as a Bearer token to the app server's /negotiate endpoint.

Description

  • Fix: Reset this._httpClient._accessToken = undefined at the top of _startInternal, alongside the existing factory resets.
// Before
this._accessTokenFactory = this._options.accessTokenFactory;
this._httpClient._accessTokenFactory = this._accessTokenFactory;

// After
this._accessTokenFactory = this._options.accessTokenFactory;
this._httpClient._accessToken = undefined;  // ← added
this._httpClient._accessTokenFactory = this._accessTokenFactory;
  • Test: Added HttpConnection.test.ts case verifying that after a redirected negotiate (with returned access token), the reconnect negotiate to the original app server does not include the redirect-scoped token.
Original prompt

This section details on the original issue you should resolve

<issue_title>SignalR JS w/ Azure SignalR Service incorrectly sends Azure SignalR JWT to application.</issue_title>
<issue_description>### Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

  • Have a simple ASP.NET Core web server with a mapped SignalR hub
  • Support both Cookie and JWT auth on the web server
  • Use .AddAzureSignalR on the server.
  • Connect to the hub with @microsoft/signalr JS, observe it correctly negotiates over to use Azure SignalR service.
  • Lose connectivity, e.g. by setting browser network tools offline and waiting 30-60 seconds.
  • Observe that reconnect/retry calls to /negotiate against the application server (NOT the /negotiate to Azure SignalR) now include a JWT. This JWT's audience is https://my-signalr-service.service.signalr.net/client/?hub=myhub, but is being sent to my application server. This confuses my application server that also happens to accept JWTs for other, signalr-unrelated purposes because this JWT is not valid for the validation parameters of my server (invalid audience, for one).

Expected Behavior

JWTs obtained for Azure SignalR Service are never sent back to the original application server in /negotiate calls. These JWTs are only meant for Azure SignalR Service and should therefore only be sent to Azure SignalR Service endpoints.

Steps To Reproduce

https://github.com/ascott18/2026-03-02-azure-signalr-wrong-jwt

Exceptions (if any)

No response

.NET Version

10.0.200-preview.0.26103.119

Anything else?

No response</issue_description>

Comments on the Issue (you are @copilot in this section)

@BrennanConroy Does adding a noop `accessTokenFactory` workaround the issue? `.withUrl("/myHub", { accessTokenFactory: () => null })` @BrennanConroy Does that mean it worked? 😆 If so then I know where the bug is, otherwise will need to keep searching. @BrennanConroy We should be resetting the `httpClient._accessToken` field [here](https://github.com/dotnet/aspnetcore/blob/2fbb3f34f85af31672ac130920d0767deabbdc45/src/SignalR/clients/ts/signalr/src/HttpConnection.ts#L230) like we're resetting the `_accessTokenFactory` field. And of course verify with a test that when reconnecting to a server that uses redirected negotiate with a new token, we don't send the new token on the reconnect.

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

…alR JWT leaking to app server

Co-authored-by: BrennanConroy <7574801+BrennanConroy@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix SignalR JS sending incorrect JWT to application Fix SignalR JS: don't send Azure SignalR JWT to app server on reconnect Mar 3, 2026
@github-actions github-actions Bot added the area-signalr Includes: SignalR clients and servers label Mar 3, 2026
@BrennanConroy BrennanConroy marked this pull request as ready for review March 4, 2026 01:11
Copilot AI review requested due to automatic review settings March 4, 2026 01:11
@BrennanConroy BrennanConroy requested a review from a team as a code owner March 4, 2026 01:11
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a SignalR TypeScript client reconnect edge case where an Azure SignalR Service redirect token (JWT) could be erroneously re-used as a Bearer token when renegotiating against the original app server.

Changes:

  • Reset the cached _httpClient._accessToken at the start of _startInternal to avoid leaking redirect-scoped tokens on reconnect.
  • Add a regression test covering redirected negotiate followed by a stop/start reconnect.
  • Update SignalR TS changelog and tighten the [no changelog] override detection in CodeCheck.ps1.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/SignalR/clients/ts/signalr/src/HttpConnection.ts Clears cached access token on (re)start so redirect tokens don’t get sent to the original negotiate endpoint.
src/SignalR/clients/ts/signalr/tests/HttpConnection.test.ts Adds coverage to ensure reconnect negotiate to the app server doesn’t include the redirect token.
src/SignalR/clients/ts/CHANGELOG.md Documents the client behavior change for the next preview release.
eng/scripts/CodeCheck.ps1 Uses literal string matching for the [no changelog] override marker.
Comments suppressed due to low confidence (1)

src/SignalR/clients/ts/signalr/tests/HttpConnection.test.ts:782

  • There’s commented-out code in the options object (accessTokenFactory: () => null). Since this test is intentionally omitting the option, it would be clearer to remove the commented line and keep the intent in a short comment (or set the property to undefined explicitly if needed).
                ...commonOptions,
                // explicitly do not provide an access token factory to verify we correctly reset state
                // accessTokenFactory: () => null,
                httpClient,

Comment thread src/SignalR/clients/ts/CHANGELOG.md Outdated

## v11.0.0-preview.3

- Don't send Azure SignalR Service access token to app server on reconnect when accessTokenFactory is null. [#65638](https://github.com/dotnet/aspnetcore/pull/65638)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog entry is a bit misleading: this fix is about clearing a cached redirect-scoped access token so it isn’t sent to the original app server on a subsequent start/reconnect. It’s not specific to accessTokenFactory being null (the option is typically undefined when not provided, and the bug is triggered by a negotiate redirect returning an access token). Consider rewording the bullet to describe the redirected-negotiate/reconnect behavior rather than accessTokenFactory being null.

Suggested change
- Don't send Azure SignalR Service access token to app server on reconnect when accessTokenFactory is null. [#65638](https://github.com/dotnet/aspnetcore/pull/65638)
- Don't send a redirect-scoped Azure SignalR Service access token obtained from a negotiate redirect to the original app server on subsequent start/reconnect. [#65638](https://github.com/dotnet/aspnetcore/pull/65638)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 493ce77 ΓÇö took your wording.

@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Looks like this PR hasn't been active for some time and the codebase could have been changed in the meantime.
To make sure no conflicting changes have occurred, please rerun validation before merging. You can do this by leaving an /azp run comment here (requires commit rights), or by simply closing and reopening.

@dotnet-policy-service dotnet-policy-service Bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Mar 11, 2026
Comment thread src/SignalR/clients/ts/CHANGELOG.md Outdated
@@ -1,5 +1,9 @@
Change log contains changes for both @microsoft/signalr and @microsoft/signalr-protocol-msgpack.

## v11.0.0-preview.3
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## v11.0.0-preview.3
## v11.0.0-preview.5

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 493ce77.

Comment thread eng/scripts/CodeCheck.ps1
# Only enforce changelog rule if there are relevant TS changes
if ($tsChanges.Count -gt 0 -and -not $changelogChanged) {
# Check if the override marker exists in recent commit messages
$hasOverride = git log origin/$targetBranch..HEAD --pretty=%B | Select-String -Pattern $signalrChangelogOverrideMarker -Quiet
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was not using -SimpleMatch breaking anything?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// as part of negotiating
let url = this.baseUrl;
this._accessTokenFactory = this._options.accessTokenFactory;
this._httpClient._accessToken = undefined;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearing out stale state at startup feels like a bit of an antipattern. I wonder if we could clear out the _httpClient._accessToken sooner as soon as we're done with the negotiate redirects.

I think the cleaner thing would be to pass the accessToken as an explicit parameter to _createTransport. Therefore, the transport and the access token always get cleaned up together making it impossible to get out of sync.

Also, I know it's not new to this PR, but do we need this._httpClient._accessTokenFactory and this._accessTokenFactory. I get that the options could be rewritten, but the closure could be modified too. I doubt anyone is really changing the reference, and it's just more state we need to keep track of.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored in 493ce77: redirect-scoped access token is now plumbed explicitly through _createTransport / _constructTransport (and _resolveTransportOrError), and the duplicate HttpConnection._accessTokenFactory field is gone. _httpClient._accessToken still has to be set during the negotiate redirect loop (so the next negotiate carries it) and is left on the client for the LongPollingTransport's lifetime, but the _createTransport/transport pairing is now the single thing that "owns" the token, and the reset at the top of _startInternal is the symmetric clear for restart. Let me know if you wanted me to go further (e.g. pull _accessToken off AccessTokenHttpClient entirely).

const options: IHttpConnectionOptions = {
...commonOptions,
// explicitly do not provide an access token factory to verify we correctly reset state
// accessTokenFactory: () => null,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So is the above a workaround that clears out the access token even without this fix?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes ΓÇö setting accessTokenFactory: () => null is the workaround I suggested on the issue, because the factory overwrites the cached _accessToken on the next negotiate. The test intentionally leaves the factory unset so the regression actually reproduces without the fix. Replaced the dead commented-out line with a real comment explaining that in 493ce77.

// accessTokenFactory: () => null,
httpClient,
logger,
transport: HttpTransportType.LongPolling,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we test any other transports?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it adds coverage: the bug is in the /negotiate request path on the second start(), which goes through AccessTokenHttpClient.send regardless of which transport ends up being selected. The transport isn't even constructed until after both negotiates are done. Happy to add WS/SSE variants if you'd still like, but they'd be testing the same code path.

… up test

Addresses review feedback on #65638:
- Pass redirect-scoped access token explicitly through _createTransport/_constructTransport
  instead of relying on stale mutable state on HttpConnection / AccessTokenHttpClient
- Drop the duplicate HttpConnection._accessTokenFactory field; rely on _httpClient
- Reword CHANGELOG entry to describe the actual scenario and bump to preview.5
- Replace dead commented-out option in test with a real explanatory comment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
BrennanConroy and others added 2 commits May 21, 2026 15:34
The refactor in 493ce77 changed _constructTransport's SSE branch to pass only
redirectAccessToken to ServerSentEventsTransport. When there's no negotiate
redirect, redirectAccessToken is undefined but the user's accessTokenFactory
has already populated _httpClient._accessToken during the initial /negotiate.
The original code passed _httpClient._accessToken so SSE got the user's token
in the access_token query string. Restore that fallback so the TypeScript
functional test 'hubConnection over ServerSentEvents transport can connect
to hub with authorization' passes again.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-signalr Includes: SignalR clients and servers pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SignalR JS w/ Azure SignalR Service incorrectly sends Azure SignalR JWT to application.

4 participants