Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions docs/identity-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -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`. |
Expand Down Expand Up @@ -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
Expand All @@ -129,17 +131,18 @@ 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 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

- [ ] 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
Expand Down
2 changes: 1 addition & 1 deletion docs/worker-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/Dbosoft.Bote.Primitives/BotePeers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,13 @@ public static BotePeers Of(IEnumerable<string> clientIds)
public bool Allows(string clientId) => _everyone || _clientIds.Contains(clientId);

/// <summary>
/// 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
/// <c>/</c>, which no client id contains. SignalR hands the claims to the worker as HTTP
/// headers, so the value must not contain line breaks.
/// </summary>
public string Format() => _everyone ? Everyone : string.Join('\n', _clientIds.Order(StringComparer.Ordinal));
public string Format() => _everyone ? Everyone : string.Join('/', _clientIds.Order(StringComparer.Ordinal));

/// <summary>Reads what <see cref="Format"/> wrote; nothing is none.</summary>
public static BotePeers Parse(string? value) =>
string.IsNullOrEmpty(value) ? None : Of(value.Split('\n'));
string.IsNullOrEmpty(value) ? None : Of(value.Split('/'));
}
2 changes: 2 additions & 0 deletions src/client/src/Dbosoft.Bote.Rebus/SignalRClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@

var subject = new ClaimsIdentity(
[
new Claim(JwtRegisteredClaimNames.Sub, credentials.ClientId),

Check warning on line 172 in src/client/src/Dbosoft.Bote.Rebus/SignalRClient.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'value' in 'Claim.Claim(string type, string value)'.
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
]);

Expand All @@ -184,6 +184,8 @@
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);
Expand All @@ -195,7 +197,7 @@
[OpenIdConnectParameterNames.ClientId] = credentials.ClientId,
[OpenIdConnectParameterNames.ClientAssertionType] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
[OpenIdConnectParameterNames.ClientAssertion] = jwt,
["scope"] = credentials.Scope

Check warning on line 200 in src/client/src/Dbosoft.Bote.Rebus/SignalRClient.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference assignment.
});

try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ public async Task<IActionResult> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,11 @@ public async Task<TokenValidationResult> 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)
Expand Down
17 changes: 16 additions & 1 deletion test/Dbosoft.Bote.BoteWorker.Tests/TestIdentityProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>An RSA key, published alongside the EC keys, to issue RS256 tokens like a production issuer.</summary>
public RsaSecurityKey RsaKey { get; }

public FakeTimeProvider Time { get; } = new(DateTimeOffset.UtcNow);

public int JwksRequests { get; private set; }
Expand Down Expand Up @@ -100,7 +104,7 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
var keys = _published.Select(k =>
{
var p = k.ECDsa.ExportParameters(false);
return new
return (object)new
{
kty = "EC",
crv = "P-256",
Expand All @@ -110,6 +114,17 @@ protected override Task<HttpResponseMessage> 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)
Expand Down
12 changes: 12 additions & 0 deletions test/Dbosoft.Bote.BoteWorker.Tests/TokenValidationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
14 changes: 14 additions & 0 deletions test/Dbosoft.Bote.Rebus.IntegrationTests/BoteQueuePolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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-..")]
Expand Down
Loading