diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthenticationMiddlewareTokenValidationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthenticationMiddlewareTokenValidationTests.cs index 98f7e96f..d926fac8 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthenticationMiddlewareTokenValidationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthenticationMiddlewareTokenValidationTests.cs @@ -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(() => 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")); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs index 188aed5e..aeeedaa7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs @@ -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 /// /// Builds the token validation parameters the middleware validates against: multi-tenant issuer - /// format check, audience = {clientId} / api://{clientId}, lifetime, and — the - /// algorithm-confusion defence — RequireSignedTokens 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 = {clientId} / api://{clientId} for each accepted client + /// ID, lifetime, and — the algorithm-confusion defence — RequireSignedTokens 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 (params) so a second/rotated app registration can be trusted alongside + /// the primary — see . /// internal static TokenValidationParameters BuildTokenValidationParameters( - IEnumerable signingKeys, string? clientId) + IEnumerable signingKeys, params string?[] clientIds) { var keys = signingKeys as ICollection ?? signingKeys?.ToList() ?? new List(); @@ -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:///access_as_user). @@ -292,4 +302,43 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware ValidAlgorithms = new[] { "RS256", "PS256" } }; } + + /// + /// Expands a set of app (client) IDs into the accepted token audiences: for each id, both the + /// bare {id} and the api://{id} 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 ValidateAudience=true — rejects every token (fail-closed). + /// + internal static string[] BuildValidAudiences(IEnumerable? clientIds) + { + return (clientIds ?? Array.Empty()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .SelectMany(id => new[] { id, $"api://{id}" }) + .ToArray(); + } + + /// + /// Resolves the configured accepted app (client) IDs: the primary EntraId:ClientId plus an + /// optional comma/semicolon/whitespace-separated EntraId:AdditionalClientIds. 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). + /// + internal static string[] ResolveConfiguredClientIds(string? primaryClientId, string? additionalClientIds) + { + var ids = new List { 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(); + } }