feat(auth): config-driven audience set — trust a second app registration

Make JWT audience validation accept the primary EntraId:ClientId PLUS an
optional comma/semicolon/space-separated EntraId:AdditionalClientIds. Each
client ID expands to {id} and api://{id}; the set is trimmed, empties dropped,
and de-duplicated case-insensitively (fail-closed on empty input).

This is the seam that lets the API trust a second/rotated app registration
during a future Entra app-reg tenant move: adding a client ID to
EntraId__AdditionalClientIds is the only backend step needed. When the setting
is unset the accepted audiences are exactly the primary id — today's behaviour,
zero change for production.

BuildTokenValidationParameters now takes params string?[] clientIds (backward
compatible), with two pure, unit-tested helpers ResolveConfiguredClientIds and
BuildValidAudiences. +12 tests (multi-audience accept, primary still valid,
foreign audience rejected, parsing/dedupe/fail-closed). 64/64 auth tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-13 16:44:53 +02:00
parent da7d663c3b
commit bffbf35875
2 changed files with 149 additions and 7 deletions
@@ -166,4 +166,97 @@ public class AuthenticationMiddlewareTokenValidationTests
$"https://login.microsoftonline.com/{Tid}/v2.0",
AuthenticationMiddleware.BuildTenantAuthority(V2Issuer, Tid));
}
// ── Multi-audience: trust a second/rotated app registration alongside the primary ────
// Config seam (EntraId:AdditionalClientIds) for the tenant-move window. See the runbook.
private const string SecondaryClientId = "99998888-7777-6666-5555-444433332222";
private static ClaimsPrincipal ValidateWith(string jwt, params string?[] clientIds)
{
var tvp = AuthenticationMiddleware.BuildTokenValidationParameters(
new SecurityKey[] { RsaKey }, clientIds);
return new JwtSecurityTokenHandler().ValidateToken(jwt, tvp, out _);
}
[Fact]
public void SecondaryConfiguredAudience_is_accepted_alongside_primary()
{
var principal = ValidateWith(
Mint(RsaCreds, audience: $"api://{SecondaryClientId}"), ClientId, SecondaryClientId);
Assert.NotNull(principal);
}
[Fact]
public void PrimaryAudience_still_accepted_when_secondary_configured()
{
var principal = ValidateWith(Mint(RsaCreds, audience: ClientId), ClientId, SecondaryClientId);
Assert.NotNull(principal);
}
[Fact]
public void AudienceOutsideConfiguredSet_is_rejected_even_with_secondary()
{
Assert.Throws<SecurityTokenInvalidAudienceException>(() => ValidateWith(
Mint(RsaCreds, audience: "api://some-third-app"), ClientId, SecondaryClientId));
}
// ── BuildValidAudiences: id → {id, api://id}, deduped, empties dropped ────────────────
[Fact]
public void BuildValidAudiences_expands_each_id_to_bare_and_api_forms()
{
Assert.Equal(
new[] { "aaa", "api://aaa", "bbb", "api://bbb" },
AuthenticationMiddleware.BuildValidAudiences(new[] { "aaa", "bbb" }));
}
[Fact]
public void BuildValidAudiences_trims_dedupes_case_insensitively_and_drops_empties()
{
Assert.Equal(
new[] { "aaa", "api://aaa" },
AuthenticationMiddleware.BuildValidAudiences(new[] { "aaa", " aaa ", "", " ", null, "AAA" }));
}
[Fact]
public void BuildValidAudiences_empty_or_null_input_is_empty_fail_closed()
{
Assert.Empty(AuthenticationMiddleware.BuildValidAudiences(null));
Assert.Empty(AuthenticationMiddleware.BuildValidAudiences(new string?[] { null, " " }));
}
// ── ResolveConfiguredClientIds: primary + optional AdditionalClientIds parsing ────────
[Theory]
[InlineData("primary", null, new[] { "primary" })] // unset ⇒ today's behaviour
[InlineData("primary", "", new[] { "primary" })]
[InlineData("primary", " ", new[] { "primary" })]
[InlineData("primary", "second", new[] { "primary", "second" })]
[InlineData("primary", "second,third", new[] { "primary", "second", "third" })]
[InlineData("primary", "second; third , fourth", new[] { "primary", "second", "third", "fourth" })]
[InlineData("primary", "primary,second", new[] { "primary", "second" })] // dedupe vs primary
public void ResolveConfiguredClientIds_parses_trims_and_dedupes(
string primary, string? additional, string[] expected)
{
Assert.Equal(expected, AuthenticationMiddleware.ResolveConfiguredClientIds(primary, additional));
}
[Fact]
public void ResolveConfiguredClientIds_dedupes_case_insensitively()
{
Assert.Equal(
new[] { "Primary", "second" },
AuthenticationMiddleware.ResolveConfiguredClientIds("Primary", "primary, second"));
}
[Fact]
public void ResolveConfiguredClientIds_null_primary_falls_back_to_additional_only()
{
Assert.Equal(
new[] { "second" },
AuthenticationMiddleware.ResolveConfiguredClientIds(null, "second"));
}
}
@@ -158,8 +158,16 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
// RS256/PS256 algorithm whitelist that hard-blocks alg:none and HS-family confusion).
// Extracted to a pure seam so those rejects are unit-testable against locally-minted
// tokens without a live OIDC ConfigurationManager.
//
// Audience = the primary app registration (EntraId:ClientId) PLUS any additional
// (secondary/rotated) client IDs from EntraId:AdditionalClientIds. That config is the
// seam that lets the API trust a SECOND app registration during a tenant-move window
// (see tasks/migration-cross-tenant-runbook.md). Unset today ⇒ exactly the primary id
// ⇒ zero behaviour change.
var clientIds = ResolveConfiguredClientIds(
_configuration["EntraId:ClientId"], _configuration["EntraId:AdditionalClientIds"]);
var validationParameters = BuildTokenValidationParameters(
openIdConfig.SigningKeys, _configuration["EntraId:ClientId"]);
openIdConfig.SigningKeys, clientIds);
var principal = _tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
@@ -251,13 +259,15 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
/// <summary>
/// Builds the token validation parameters the middleware validates against: multi-tenant issuer
/// format check, audience = <c>{clientId}</c> / <c>api://{clientId}</c>, lifetime, and — the
/// algorithm-confusion defence — <c>RequireSignedTokens</c> plus the RS256/PS256 whitelist that
/// hard-blocks alg:none and the HS family. Signing keys are supplied by the caller (the tenant's
/// OIDC config at runtime; a test key under unit test).
/// format check, audience = <c>{clientId}</c> / <c>api://{clientId}</c> for each accepted client
/// ID, lifetime, and — the algorithm-confusion defence — <c>RequireSignedTokens</c> plus the
/// RS256/PS256 whitelist that hard-blocks alg:none and the HS family. Signing keys are supplied by
/// the caller (the tenant's OIDC config at runtime; a test key under unit test). Accepts one or
/// more client IDs (<c>params</c>) so a second/rotated app registration can be trusted alongside
/// the primary — see <see cref="ResolveConfiguredClientIds"/>.
/// </summary>
internal static TokenValidationParameters BuildTokenValidationParameters(
IEnumerable<SecurityKey> signingKeys, string? clientId)
IEnumerable<SecurityKey> signingKeys, params string?[] clientIds)
{
var keys = signingKeys as ICollection<SecurityKey> ?? signingKeys?.ToList() ?? new List<SecurityKey>();
@@ -275,7 +285,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
throw new SecurityTokenInvalidIssuerException($"Invalid issuer: {iss}");
},
ValidateAudience = true,
ValidAudiences = new[] { clientId, $"api://{clientId}" },
ValidAudiences = BuildValidAudiences(clientIds),
ValidateLifetime = true,
// Signature validation enabled (token minted for api://<clientId>/access_as_user).
@@ -292,4 +302,43 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
ValidAlgorithms = new[] { "RS256", "PS256" }
};
}
/// <summary>
/// Expands a set of app (client) IDs into the accepted token audiences: for each id, both the
/// bare <c>{id}</c> and the <c>api://{id}</c> form. Empty/whitespace ids are dropped and the
/// result is de-duplicated case-insensitively (order preserved). An empty input yields an empty
/// audience set, which — with <c>ValidateAudience=true</c> — rejects every token (fail-closed).
/// </summary>
internal static string[] BuildValidAudiences(IEnumerable<string?>? clientIds)
{
return (clientIds ?? Array.Empty<string?>())
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.SelectMany(id => new[] { id, $"api://{id}" })
.ToArray();
}
/// <summary>
/// Resolves the configured accepted app (client) IDs: the primary <c>EntraId:ClientId</c> plus an
/// optional comma/semicolon/whitespace-separated <c>EntraId:AdditionalClientIds</c>. Trimmed,
/// empties dropped, de-duplicated case-insensitively (primary first). When AdditionalClientIds is
/// unset the result is exactly the primary id — today's behaviour, zero change — so adding a
/// second app registration's client ID to that setting is the ONLY step needed to trust a second
/// app during a tenant-move window (tasks/migration-cross-tenant-runbook.md).
/// </summary>
internal static string[] ResolveConfiguredClientIds(string? primaryClientId, string? additionalClientIds)
{
var ids = new List<string?> { primaryClientId };
if (!string.IsNullOrWhiteSpace(additionalClientIds))
{
ids.AddRange(additionalClientIds.Split(
new[] { ',', ';', ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries));
}
return ids
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}