mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+93
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user