Skip to content

Make authentication behave the same in every client - #80

Merged
windischb merged 7 commits into
developfrom
claude/auth-credentials
Sep 25, 2026
Merged

windischb merged 7 commits into
developfrom
claude/auth-credentials

Conversation

@windischb

Copy link
Copy Markdown
Collaborator

Why

An app with .NET, TypeScript, iOS and Android clients needs authentication to behave the same everywhere. It didn't. Swift and Kotlin put the connection token into the transport URL, where proxy logs and error reports keep it. The clients used five names for two credentials and had two different defaults. TypeScript and Swift could not tell unauthorized from other errors without matching the message. Background: the amZettel feature request "Token beim Transport als Header statt in der URL".

What

Token as a header on the transport (a9e3f5a)

  • Swift and Kotlin now send the connection token as an Authorization header on the WebSocket upgrade, on SSE and on Long Polling. It is fetched again on every connect.
  • transportCredential = .query / QUERY keeps the URL for servers that read the token only there.
  • UseSignalARRRAccessTokenValidation now only fills in a missing header. It no longer overwrites a header the client sent. The middleware is now documented.

The same credential options in every client (03b79ea, e64352a)

  • New options: credential, connectionCredential, messageCredential. In .NET and .NET Framework they are WithCredential, WithConnectionCredential and WithMessageCredential.
  • Nothing is coupled implicitly anymore.
  • A credential set in two places is an error:
    • at Create/create in .NET, .NET Framework, TypeScript and Kotlin;
    • from start() in Swift, whose create does not throw.
  • The former options keep working and their behaviour, and are marked deprecated: WithAuthorization, authorization, messageAccessTokenProvider, Swift's accessTokenFactory:, and the .NET Framework accessTokenProvider parameter.
  • TypeScript gains create(url, options, configure?), so SignalARRR can hand the connection credential to SignalR.
  • Every client guide and the authorization guide show the same table.
  • Two doc errors are fixed: file transfers and challenge answers use the message credential, not the connection's.

Remaining gaps closed (b9c763f)

  • Error codes (code, normalizedCode, HARRRErrorCodes) in TypeScript and Swift, which .NET and Kotlin already had.
  • statusCode on a rejected negotiate:
    • Kotlin: on NegotiationFailedException.
    • Swift: on SignalRError.
    • TypeScript: on the error start() rejects with. SignalR's JS client mentions the status only in its message.
    • .NET already had HttpRequestException.StatusCode; a test now pins it down.
  • A headers option in Swift.

Verification

  • Probe: the test server gets a hub method TransportCredential() that reports where the transport request carried the token.
  • .NET: 288/288 unit tests (net8.0/9.0/10.0) and 108 integration tests (net10.0). WithCredential is tested end to end against a hub with [Authorize].
  • .NET Framework: 25/25 on net48. This confirms that SignalR's .NET Framework client sends a header on all three transports.
  • TypeScript: 37 unit tests and 74 integration tests.
  • Kotlin: 109 unit tests and 74 integration tests. Both modes are tested over all three transports.
  • Swift: tests are written for the same cases, but the package was not compiled locally because there is no toolchain on the dev machine. The CI macOS jobs are the first build.

🤖 Generated with Claude Code

windischb and others added 7 commits September 25, 2026 14:13
Swift and Kotlin carried the connection token in the transport URL as
access_token, where proxy logs and error reports keep it. Both now send it as
an Authorization header on the WebSocket upgrade, the SSE stream and posts and
every Long Polling request, fetched again on every connect. A new
transportCredential option (.query / QUERY) keeps the URL for servers that read
it only there.

UseSignalARRRAccessTokenValidation now only fills in a missing Authorization
header instead of overwriting one the client sent. The middleware is
documented, with a table of where each client sends the connection token.

The integration test hub reports where the transport request carried the
token; Kotlin and Swift test both modes over all three transports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The clients used five names for two credentials and two different defaults:
.NET and TypeScript kept connection and message credential apart, Swift and
Kotlin quietly used one for both. Every client now has connectionCredential,
messageCredential and credential for both (WithConnectionCredential,
WithMessageCredential, WithCredential in .NET), with nothing coupled
implicitly.

The former options keep working and their behaviour: WithAuthorization,
authorization, messageAccessTokenProvider and Swift's accessTokenFactory are
deprecated. A credential set in two places is an error: at create in .NET,
TypeScript and Kotlin, from start() in Swift, whose create does not throw.

In .NET and TypeScript the connection credential becomes SignalR's
AccessTokenProvider/accessTokenFactory, so it needs the overload where
SignalARRR builds the connection; TypeScript gains create(url, options,
configure?).

Every client guide and the authorization guide show the same table. Two doc
errors are fixed: file transfers and challenge answers use the message
credential, not the connection's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Error codes in TypeScript and Swift: invoke() in TypeScript rejects with code
and normalizedCode, Swift's HARRRError carries code, version and
normalizedCode, and both export HARRRErrorCodes. Both accept an envelope by the
same test as .NET and Kotlin, so a code-only envelope is no longer dropped.

The HTTP status of a rejected negotiate: statusCode on Kotlin's
NegotiationFailedException, Swift's SignalRError and the error TypeScript's
start() rejects with (SignalR's JS client only mentions it in the message).
.NET already had HttpRequestException.StatusCode; a test pins it down.

A headers option in the Swift client, applied to negotiate and every
transport request, as in Kotlin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The .NET Framework client took one bare accessTokenProvider parameter which,
despite SignalR's meaning of that name, was the message credential. It now
has the .NET client's options: Create(builder, options => options
.WithCredential/WithConnectionCredential/WithMessageCredential), with the same
errors for a credential set in two places. The former parameter keeps working
and is marked obsolete.

A test on net48 confirms that SignalR's .NET Framework client sends the
connection token as a header on WebSocket, SSE and Long Polling; the docs'
transport table and the client comparison say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tials

# Conflicts:
#	CHANGELOG.md
#	website/changelog.md
ASP.NET Core answers the first poll after negotiate at once and without data;
it completes the connection on the server. The .NET, TypeScript and Kotlin
clients send it when they open the transport. The Swift client did not, so the
handshake read got the empty answer and failed with "Incomplete handshake
response" - Long Polling never connected. The new transport tests were the
first to exercise it end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@windischb
windischb merged commit 9f57715 into develop Sep 25, 2026
13 checks passed
@windischb
windischb deleted the claude/auth-credentials branch September 26, 2026 09:51
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.

1 participant