From 11237b41871b98194e303156352c6e73c45ed4ca Mon Sep 17 00:00:00 2001 From: Frank Wagner Date: Sat, 24 Oct 2026 01:26:49 +0200 Subject: [PATCH 1/4] Align with a production identity provider: accept RS256, explicitly typed client assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker validated only ES256 access tokens; a production issuer (e.g. OpenIddict) signs RS256. Accept both RS256 and ES256 — both asymmetric, with the key type matched to the algorithm, so no algorithm-confusion risk — while HMAC and unsigned tokens stay refused. Client assertions now carry the header typ=client-authentication+jwt (RFC 8725 explicit typing), which such issuers require, and the development BasicIdentityProvider now requires that typ too so it matches. The identity-provider contract doc is updated accordingly (RS256 or ES256, RSA or EC JWKS, the assertion typ). --- docs/identity-provider.md | 20 +++++++++++-------- .../src/Dbosoft.Bote.Rebus/SignalRClient.cs | 2 ++ .../TokenIssuer.cs | 4 +++- .../TokenValidationService.cs | 7 +++++-- .../TestIdentityProvider.cs | 17 +++++++++++++++- .../TokenValidationServiceTests.cs | 12 +++++++++++ 6 files changed, 50 insertions(+), 12 deletions(-) diff --git a/docs/identity-provider.md b/docs/identity-provider.md index 84bb4d8..51460dd 100644 --- a/docs/identity-provider.md +++ b/docs/identity-provider.md @@ -16,7 +16,7 @@ client identity provider worker | signed with client key) | | |-------------------------->| checks it against the | | | registered public key | - |<--------------------------| access token (ES256): | + |<--------------------------| access token (RS256/ES256): | | tid, sub, scope, bote_peers | |------------------------------------------------------------->| negotiate: validates against | | the JWKS, opens a SignalR @@ -50,6 +50,7 @@ The client assertion is signed with ES256 by the client's own ECDSA P-256 key an | `jti` | a new GUID for every assertion | | `nbf`, `exp` | now, and five minutes later | | header `kid` | the key id of the client's signing key | +| header `typ` | `client-authentication+jwt` (explicit typing, RFC 8725; the identity provider requires it) | The identity provider must: @@ -70,7 +71,7 @@ Rotating a client's key: register the new public key next to the old one and tel | | Requirement | | --- | --- | -| Signature | ES256 (ECDSA P-256 with SHA-256). The worker refuses every other algorithm, RS256 included. | +| Signature | RS256 (RSA) or ES256 (ECDSA P-256). The worker accepts these two asymmetric algorithms and refuses every other (no HMAC, no `none`). A production issuer such as OpenIddict signs RS256; the development BasicIdentityProvider signs ES256. | | header `kid` | The id of the signing key in the JWKS. Without it rotation cannot work (see below). | | `iss` | Exactly the worker's `OpenId:Authority`, character for character. | | `aud` | Contains the worker's `OpenId:Audience`. | @@ -111,7 +112,8 @@ talks to many. ## Signing keys (JWKS) - Publish the signing keys as a JSON Web Key Set at the worker's `OpenId:JwksUri`, over HTTPS and - reachable from the worker: `kty` `EC`, `crv` `P-256`, `x`, `y`, `kid`. + reachable from the worker: RSA keys (`kty` `RSA`, `n`, `e`, `kid`) or EC keys (`kty` `EC`, + `crv` `P-256`, `x`, `y`, `kid`), matching the signing algorithm (RS256 / ES256). - The worker caches the set for an hour. A token whose `kid` it does not know makes it load the set again, at most every 30 seconds, so a new key works within moments of being published. - Rotation: publish the new key, then sign with it. Keep the old key published until the last @@ -129,17 +131,19 @@ identity provider stops issuing tokens to a client: ## Off-the-shelf identity providers -Entra ID and Azure AD B2C cannot issue these tokens as they are: they sign with RS256, put an -application's permissions in `roles` instead of `scope`, and use `tid` for their own directory. -An application needs an issuer it controls, for example a token service of its own, -OpenIddict or Duende IdentityServer, configured for ES256 and the claims above. +Entra ID and Azure AD B2C still cannot issue these tokens as they are: although the worker now +accepts RS256, they put an application's permissions in `roles` instead of `scope` and use `tid` +for their own directory. An application needs an issuer it controls — for example a token service +of its own, OpenIddict or Duende IdentityServer — configured for RS256 or ES256 and the claims +above. ## Checklist - [ ] Client credentials with private key JWT, assertion verified against the registered P-256 key - [ ] `jti` replay refused - [ ] Tenant taken from the registration -- [ ] Access token ES256 with `kid` +- [ ] Access token RS256 or ES256 with `kid` +- [ ] Client assertion explicitly typed `typ: client-authentication+jwt` - [ ] `iss`, `aud`, `exp` as the worker is configured - [ ] `sub`, `tid`, `scope` (a string), `bote_peers` for clients that talk to other clients - [ ] Scopes and peers granted from the registration diff --git a/src/client/src/Dbosoft.Bote.Rebus/SignalRClient.cs b/src/client/src/Dbosoft.Bote.Rebus/SignalRClient.cs index 7c274af..e8f84f3 100644 --- a/src/client/src/Dbosoft.Bote.Rebus/SignalRClient.cs +++ b/src/client/src/Dbosoft.Bote.Rebus/SignalRClient.cs @@ -184,6 +184,8 @@ public async Task IsClientConnected(string clientId) Audience = credentials.Authority, Issuer = credentials.ClientId, IncludeKeyIdInHeader = true, + // Explicitly type the client assertion (RFC 8725); the identity provider (OpenIddict) requires this typ. + TokenType = "client-authentication+jwt", }; var jwt = handler.CreateToken(tokenDescriptor); diff --git a/src/workers/src/Dbosoft.Bote.BasicIdentityProvider/TokenIssuer.cs b/src/workers/src/Dbosoft.Bote.BasicIdentityProvider/TokenIssuer.cs index ec57f1f..1061ae6 100644 --- a/src/workers/src/Dbosoft.Bote.BasicIdentityProvider/TokenIssuer.cs +++ b/src/workers/src/Dbosoft.Bote.BasicIdentityProvider/TokenIssuer.cs @@ -67,12 +67,14 @@ public async Task GetToken( logger.LogInformation("Expected audience: {Audience}", options.Value.Authority); - // Validate client assertion: issuer must be client ID, audience must be authority + // Validate client assertion: issuer must be client ID, audience must be authority, and the assertion must be + // explicitly typed (RFC 8725) as a client assertion — matching the production identity provider (OpenIddict). var assertionTokenResult = await handler.ValidateTokenAsync(assertionToken, new TokenValidationParameters() { ValidIssuer = clientId, ValidAudience = options.Value.Authority, IssuerSigningKey = clientKey, + ValidTypes = new[] { "client-authentication+jwt" }, }); if (!assertionTokenResult.IsValid) diff --git a/src/workers/src/Dbosoft.Bote.BoteWorker/TokenValidationService.cs b/src/workers/src/Dbosoft.Bote.BoteWorker/TokenValidationService.cs index 95e7e76..03f9546 100644 --- a/src/workers/src/Dbosoft.Bote.BoteWorker/TokenValidationService.cs +++ b/src/workers/src/Dbosoft.Bote.BoteWorker/TokenValidationService.cs @@ -68,8 +68,11 @@ public async Task ValidateAccessToken(string jwt, Cancell RequireExpirationTime = true, RequireSignedTokens = true, - // CRITICAL: Restrict allowed algorithms to prevent algorithm confusion attacks - ValidAlgorithms = new[] { SecurityAlgorithms.EcdsaSha256 }, + // CRITICAL: restrict to asymmetric signature algorithms to prevent algorithm-confusion attacks (e.g. a + // public key replayed as an HMAC secret). RS256 and ES256 are both asymmetric and the key type is matched + // to the algorithm, so accepting both is safe: a production issuer such as OpenIddict signs RS256, while + // the development BasicIdentityProvider signs ES256. + ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256, SecurityAlgorithms.EcdsaSha256 }, // Reduce clock skew for tighter security (default is 5 minutes) ClockSkew = TimeSpan.FromMinutes(2) diff --git a/test/Dbosoft.Bote.BoteWorker.Tests/TestIdentityProvider.cs b/test/Dbosoft.Bote.BoteWorker.Tests/TestIdentityProvider.cs index 35c1987..0e98293 100644 --- a/test/Dbosoft.Bote.BoteWorker.Tests/TestIdentityProvider.cs +++ b/test/Dbosoft.Bote.BoteWorker.Tests/TestIdentityProvider.cs @@ -26,10 +26,14 @@ public TestIdentityProvider() { SigningKey = NewKey("key-1"); _published = [SigningKey]; + RsaKey = new RsaSecurityKey(RSA.Create(2048)) { KeyId = "rsa-1" }; } public ECDsaSecurityKey SigningKey { get; private set; } + /// An RSA key, published alongside the EC keys, to issue RS256 tokens like a production issuer. + public RsaSecurityKey RsaKey { get; } + public FakeTimeProvider Time { get; } = new(DateTimeOffset.UtcNow); public int JwksRequests { get; private set; } @@ -100,7 +104,7 @@ protected override Task SendAsync(HttpRequestMessage reques var keys = _published.Select(k => { var p = k.ECDsa.ExportParameters(false); - return new + return (object)new { kty = "EC", crv = "P-256", @@ -110,6 +114,17 @@ protected override Task SendAsync(HttpRequestMessage reques use = "sig", alg = "ES256", }; + }).ToList(); + + var rsa = RsaKey.Rsa.ExportParameters(false); + keys.Add(new + { + kty = "RSA", + n = Base64UrlEncoder.Encode(rsa.Modulus), + e = Base64UrlEncoder.Encode(rsa.Exponent), + kid = RsaKey.KeyId, + use = "sig", + alg = "RS256", }); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) diff --git a/test/Dbosoft.Bote.BoteWorker.Tests/TokenValidationServiceTests.cs b/test/Dbosoft.Bote.BoteWorker.Tests/TokenValidationServiceTests.cs index 1667d46..c82dd55 100644 --- a/test/Dbosoft.Bote.BoteWorker.Tests/TokenValidationServiceTests.cs +++ b/test/Dbosoft.Bote.BoteWorker.Tests/TokenValidationServiceTests.cs @@ -17,6 +17,18 @@ public async Task Accepts_a_token_signed_with_a_published_key() result.IsValid.Should().BeTrue(); } + [Fact] + public async Task Accepts_an_RS256_token_from_a_production_issuer() + { + // The production identity provider (OpenIddict) signs with RS256; the worker must accept it, not only the + // development BasicIdentityProvider's ES256. + var token = _idp.Issue(signingCredentials: new SigningCredentials(_idp.RsaKey, SecurityAlgorithms.RsaSha256)); + + var result = await _idp.CreateValidator().ValidateAccessToken(token); + + result.IsValid.Should().BeTrue(); + } + [Fact] public async Task Rejects_a_token_of_another_issuer() { From cd9068691b58ec34a2da4c56f8893d4477b9b19e Mon Sep 17 00:00:00 2001 From: Frank Wagner Date: Sat, 24 Oct 2026 01:32:37 +0200 Subject: [PATCH 2/4] Reference doc: drop changelog-style wording about RS256 acceptance --- docs/identity-provider.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/identity-provider.md b/docs/identity-provider.md index 51460dd..e0b52c4 100644 --- a/docs/identity-provider.md +++ b/docs/identity-provider.md @@ -131,11 +131,10 @@ identity provider stops issuing tokens to a client: ## Off-the-shelf identity providers -Entra ID and Azure AD B2C still cannot issue these tokens as they are: although the worker now -accepts RS256, they put an application's permissions in `roles` instead of `scope` and use `tid` -for their own directory. An application needs an issuer it controls — for example a token service -of its own, OpenIddict or Duende IdentityServer — configured for RS256 or ES256 and the claims -above. +Entra ID and Azure AD B2C cannot issue these tokens as they are: they put an application's +permissions in `roles` instead of `scope` and use `tid` for their own directory. An application +needs an issuer it controls — for example a token service of its own, OpenIddict or Duende +IdentityServer — configured for RS256 or ES256 and the claims above. ## Checklist From d253b01a00d3c48b8e9e486702909032e34e9b27 Mon Sep 17 00:00:00 2001 From: Frank Wagner Date: Sat, 24 Oct 2026 12:40:20 +0200 Subject: [PATCH 3/4] Note RS256 in the worker configuration reference The worker-configuration table-section text still said the worker accepts ES256 tokens only; it accepts RS256 and ES256. Align the wording with the validation change. --- docs/worker-configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/worker-configuration.md b/docs/worker-configuration.md index e1b0bb5..d6bee2d 100644 --- a/docs/worker-configuration.md +++ b/docs/worker-configuration.md @@ -81,7 +81,7 @@ as a Key Vault reference, which SignalR resolves with its own identity. | `RequiredScope` | yes | A scope every token must carry (scopes are a space-separated set); without it the worker refuses every client. | | `PeerScope` | no | A client whose token carries this scope too may send to the clients of its tenant its token lists in `bote_peers`, e.g. `bote.peer`. Unset, clients only talk to the cloud. | -The worker takes the client from `sub` and the tenant from `tid`. It accepts ES256 tokens only, +The worker takes the client from `sub` and the tenant from `tid`. It accepts RS256 and ES256 tokens only, reloads the keys when a token names an unknown key (at most every 30 seconds) and keeps using the known keys while the issuer is unreachable. Tenant and client ids must not contain `/`, `\` or control characters, nor be `.` or `..`. `BasicIdentityProvider` is for development; an application From c1258d60805878f20e9fa0ee8186c31923a1116c Mon Sep 17 00:00:00 2001 From: Frank Wagner Date: Sat, 24 Oct 2026 23:52:02 +0200 Subject: [PATCH 4/4] Separate peers in the SignalR claim with / instead of a line break SignalR hands claims to the worker as HTTP headers; a line break there broke the connection of every client listing two or more peers. --- src/Dbosoft.Bote.Primitives/BotePeers.cs | 9 +++++---- .../BoteQueuePolicyTests.cs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Dbosoft.Bote.Primitives/BotePeers.cs b/src/Dbosoft.Bote.Primitives/BotePeers.cs index 9a13bb7..61d1810 100644 --- a/src/Dbosoft.Bote.Primitives/BotePeers.cs +++ b/src/Dbosoft.Bote.Primitives/BotePeers.cs @@ -48,12 +48,13 @@ public static BotePeers Of(IEnumerable clientIds) public bool Allows(string clientId) => _everyone || _clientIds.Contains(clientId); /// - /// One string for a claim of the worker's own SignalR token; the ids are separated by line - /// breaks, which no client id contains. + /// One string for a claim of the worker's own SignalR token; the ids are separated by + /// /, which no client id contains. SignalR hands the claims to the worker as HTTP + /// headers, so the value must not contain line breaks. /// - public string Format() => _everyone ? Everyone : string.Join('\n', _clientIds.Order(StringComparer.Ordinal)); + public string Format() => _everyone ? Everyone : string.Join('/', _clientIds.Order(StringComparer.Ordinal)); /// Reads what wrote; nothing is none. public static BotePeers Parse(string? value) => - string.IsNullOrEmpty(value) ? None : Of(value.Split('\n')); + string.IsNullOrEmpty(value) ? None : Of(value.Split('/')); } diff --git a/test/Dbosoft.Bote.Rebus.IntegrationTests/BoteQueuePolicyTests.cs b/test/Dbosoft.Bote.Rebus.IntegrationTests/BoteQueuePolicyTests.cs index 2395f76..9df27ae 100644 --- a/test/Dbosoft.Bote.Rebus.IntegrationTests/BoteQueuePolicyTests.cs +++ b/test/Dbosoft.Bote.Rebus.IntegrationTests/BoteQueuePolicyTests.cs @@ -46,6 +46,20 @@ public void Client_sends_only_to_the_peers_it_is_allowed() .Should().Be(new ClientSendRoute(ClientSendTarget.Self, "client-a")); } + [Fact] + public void Peers_survive_the_signalr_claim() + { + var formatted = BotePeers.Of(["client-c", "client-b"]).Format(); + + // SignalR hands the claim to the worker as an HTTP header + formatted.Should().NotContainAny("\r", "\n"); + var peers = BotePeers.Parse(formatted); + peers.Allows("client-b").Should().BeTrue(); + peers.Allows("client-c").Should().BeTrue(); + peers.Allows("client-d").Should().BeFalse(); + BotePeers.Parse(BotePeers.All.Format()).Allows("client-d").Should().BeTrue(); + } + [Theory] [InlineData("bote-clients-")] [InlineData("bote-clients-..")]