From 9a258856767f72a855a63ce38e89fb90d3c5cf28 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 18 Nov 2022 15:22:30 -0800 Subject: [PATCH 1/8] Updated AppService and Static Web Apps EasyAuth authentication parsing and handling. Updated and added unit tests to reflect new behavior: invalid EasyAuth payload results in HTTP 401. --- src/Service.Tests/AuthTestHelper.cs | 10 +- .../EasyAuthAuthenticationUnitTests.cs | 124 +++++++++++------- .../AppServiceAuthentication.cs | 39 +++--- ...lientRoleHeaderAuthenticationMiddleware.cs | 3 +- .../EasyAuthAuthenticationDefaults.cs | 2 + .../EasyAuthAuthenticationHandler.cs | 34 ++--- 6 files changed, 130 insertions(+), 82 deletions(-) diff --git a/src/Service.Tests/AuthTestHelper.cs b/src/Service.Tests/AuthTestHelper.cs index 80d7aa3a70..3803ef57f1 100644 --- a/src/Service.Tests/AuthTestHelper.cs +++ b/src/Service.Tests/AuthTestHelper.cs @@ -34,10 +34,12 @@ public static string CreateAppServiceEasyAuthToken() Typ = ClaimTypes.Role }; - List claims = new(); - claims.Add(emailClaim); - claims.Add(roleClaimAnonymous); - claims.Add(roleClaimAuthenticated); + List claims = new() + { + emailClaim, + roleClaimAnonymous, + roleClaimAuthenticated + }; AppServiceClientPrincipal token = new() { diff --git a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs index c55f808dce..b626c54994 100644 --- a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs +++ b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs @@ -22,16 +22,16 @@ namespace Azure.DataApiBuilder.Service.Tests.Authentication [TestClass] public class EasyAuthAuthenticationUnitTests { - #region Positive Tests + #region Tests /// - /// Ensures a valid AppService EasyAuth header/value does NOT result in HTTP 401 Unauthenticated response. - /// 403 is okay, as it indicates authorization level failure, not authentication. - /// When an authorization header is sent, it contains an invalid value, if the runtime returns an error - /// then there is improper JWT validation occurring. + /// Ensures a valid AppService EasyAuth header/value results in HTTP 200 or HTTP 403. + /// HTTP 401 will not occur when EasyAuth is correctly configured (AppService environment and runtime configuration). + /// When EasyAuth is configured and an authorization header is sent, the authorization header should be ignored + /// and zero token validation errors should be observed. /// [DataTestMethod] - [DataRow(false, DisplayName = "Valid AppService EasyAuth header only")] - [DataRow(true, DisplayName = "Valid AppService EasyAuth header and authorization header")] + [DataRow(false, DisplayName = "Valid AppService EasyAuth payload - 200")] + [DataRow(true, DisplayName = "Valid AppService EasyAuth header and authorization header - 200")] [TestMethod] public async Task TestValidAppServiceEasyAuthToken(bool sendAuthorizationHeader) { @@ -50,6 +50,41 @@ public async Task TestValidAppServiceEasyAuthToken(bool sendAuthorizationHeader) ignoreCase: true); } + /// + /// Invalid AppService EasyAuth payloads elicit a 401 Unauthorized responsed, indicating + /// failed authentication. + /// + [DataTestMethod] + [DataRow("", DisplayName = "Empty JSON not serializable to AppServiceClientPrincipal")] + [DataRow("eyJtZXNzYWdlIjogImhlbGxvIHdvcmxkIn0=", DisplayName = "JSON not serializable to AppServiceClientPrincipal")] + [DataRow("aGVsbG8sIHdvcmxkIQ==", DisplayName = "Non-JSON Base64 encoded string not serializable to AppServiceClientPrincipal")] + public async Task TestInvalidAppServiceEasyAuthToken(string easyAuthPayload) + { + HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(token: easyAuthPayload, EasyAuthType.AppService); + + Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode); + Assert.IsNotNull(postMiddlewareContext.User.Identity); + Assert.AreEqual(expected: false, actual: postMiddlewareContext.User.Identity.IsAuthenticated); + } + + /// + /// Ensures authentication fails when no EasyAuth header is present because + /// a correctly configured EasyAuth environment guarantees that ALL request types (anonymous and authenticated) + /// will contain an EasyAuth header. + /// + /// AppService/StaticWebApps + [DataTestMethod] + [DataRow(EasyAuthType.AppService)] + [DataRow(EasyAuthType.StaticWebApps)] + public async Task TestMissingEasyAuthHeader(EasyAuthType easyAuthType) + { + HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(token: null, easyAuthType); + + Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode); + Assert.IsNotNull(postMiddlewareContext.User.Identity); + Assert.AreEqual(expected: false, actual: postMiddlewareContext.User.Identity.IsAuthenticated); + } + /// /// Ensures a valid StaticWebApps EasyAuth header/value does NOT result in HTTP 401 Unauthorized response. /// 403 is okay, as it indicates authorization level failure, not authentication. @@ -199,6 +234,8 @@ await SendRequestAndGetHttpContextState( generatedToken, EasyAuthType.StaticWebApps, clientRoleHeader: clientRoleHeader); + + // Validate state of HttpContext after being processed by authentication middleware. Assert.IsNotNull(postMiddlewareContext.User.Identity); Assert.AreEqual(expected: addAuthenticated, postMiddlewareContext.User.Identity.IsAuthenticated); Assert.AreEqual(expected: (int)HttpStatusCode.OK, actual: postMiddlewareContext.Response.StatusCode); @@ -208,58 +245,52 @@ await SendRequestAndGetHttpContextState( } /// - /// - Ensures an invalid/no EasyAuth header/value results in HTTP 200 OK response - /// but with the X-MS-API-ROLE assigned to be anonymous. + /// - Ensures an invalid/no EasyAuth header payload results in HTTP 401 Unauthorized response + /// A correctly configured EasyAuth environment guarantees an EasyAuth payload for authenticated and anonymous requests. /// - Also, validate that if other auth headers are present (Authorization Bearer token), that it is never considered /// when the runtime is configured for EasyAuth authentication. /// - /// EasyAuth header value + /// EasyAuth header value /// [DataTestMethod] - [DataRow("", DisplayName = "No EasyAuth header value provided")] - [DataRow("ey==", DisplayName = "Corrupt EasyAuth header value provided")] - [DataRow(null, DisplayName = "No EasyAuth header provided")] - [DataRow("", true, DisplayName = "No EasyAuth header value provided, include authorization header")] + [DataRow("", DisplayName = "No EasyAuth payload -> 401 Unauthorized")] + [DataRow("ey==", DisplayName = "Invalid EasyAuth payload -> 401 Unauthorized")] + [DataRow(null, DisplayName = "No EasyAuth header provided -> 401 Unauthorized")] + [DataRow("", true, DisplayName = "No EasyAuth payload, include authorization header")] [DataRow("ey==", true, DisplayName = "Corrupt EasyAuth header value provided, include authorization header")] [DataRow(null, true, DisplayName = "No EasyAuth header provided, include authorization header")] [TestMethod] - public async Task TestInvalidEasyAuthToken(string token, bool sendAuthorizationHeader = false) + public async Task TestInvalidStaticWebAppsEasyAuthToken(string easyAuthPayload, bool sendAuthorizationHeader = false) { HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState( - token, + easyAuthPayload, EasyAuthType.StaticWebApps, sendAuthorizationHeader); + + // Validate state of HttpContext after being processed by authentication middleware. Assert.IsNotNull(postMiddlewareContext.User.Identity); Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated); - Assert.AreEqual(expected: (int)HttpStatusCode.OK, actual: postMiddlewareContext.Response.StatusCode); - Assert.AreEqual(expected: AuthorizationType.Anonymous.ToString(), - actual: postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER], - ignoreCase: true); + Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode); + Assert.AreEqual(expected: string.Empty, actual: postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString()); } /// /// Test to validate that the request is appropriately treated as anonymous/authenticated - /// in development mode depending on the value feature switch we have in the config file. + /// in development mode depending on the value feature flag we have in the config file. + /// (Enabled/Disabled) in test DisplayName indicates state of feature flag. /// - /// Boolean value indicating whether to treat the - /// request as authenticated by default. + /// Boolean value indicating whether to turn on feature flag + /// used to treat requests as authenticated by default. /// Expected value of X-MS-API-ROLE header. /// Value of X-MS-API-ROLE header specified in request. - /// [DataTestMethod] - [DataRow(true, "Authenticated", null, - DisplayName = "EasyAuth- Treat request as authenticated in development mode")] - [DataRow(false, "Anonymous", null, - DisplayName = "EasyAuth- Treat request as anonymous in development mode")] - [DataRow(true, "author", "author", - DisplayName = "EasyAuth- Treat request as authenticated in development mode " + - "and honor the clientRoleHeader")] - [DataRow(true, "Anonymous", "Anonymous", - DisplayName = "EasyAuth- Treat request as authenticated in development mode " + - "and honor the clientRoleHeader even when specified as anonymous")] - public async Task TestAuthenticatedRequestInDevelopmentMode( - bool treatDevModeRequestAsAuthenticated, + [DataRow(false, null, null, DisplayName = "Disabled, no auth headers -> 401 Unauthorized")] + [DataRow(true, "Authenticated", null, DisplayName = "Enabled, no auth headers -> clientRoleHeader set to (authenticated)")] + [DataRow(true, "author", "author", DisplayName = "Enabled, honor the clientRoleHeader (author)")] + [DataRow(true, "Anonymous", "Anonymous", DisplayName = "Enabled, honor the clientRoleHeader (anonymous)")] + public async Task AuthenticateDevModeRequests_FeatureFlagTests( + bool authenticateDevModeRequests, string expectedClientRoleHeader, string clientRoleHeader) { @@ -268,20 +299,23 @@ await SendRequestAndGetHttpContextState( token: null, easyAuthType: EasyAuthType.StaticWebApps, clientRoleHeader: clientRoleHeader, - treatRequestAsAuthenticated: treatDevModeRequestAsAuthenticated); + treatRequestAsAuthenticated: authenticateDevModeRequests); + // Validate state of HttpContext after being processed by authentication middleware. Assert.IsNotNull(postMiddlewareContext.User.Identity); - Assert.AreEqual(expected: (int)HttpStatusCode.OK, actual: postMiddlewareContext.Response.StatusCode); - Assert.AreEqual(expected: expectedClientRoleHeader, - actual: postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString()); + int expectedStatusCode = authenticateDevModeRequests ? (int)HttpStatusCode.OK : (int)HttpStatusCode.Unauthorized; + Assert.AreEqual(expected: expectedStatusCode, actual: postMiddlewareContext.Response.StatusCode); + + string resolvedRoleHeader = postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); + if (!string.IsNullOrWhiteSpace(resolvedRoleHeader)) + { + Assert.AreEqual(expected: expectedClientRoleHeader, actual: resolvedRoleHeader); + } - // Validates that AuthenticationMiddleware adds the clientRoleHeader as a role claim - // ONLY when the DevModeAuthNFlag is set. + // Validates that AuthenticationMiddleware adds the clientRoleHeader as a role claim ONLY when the DevModeAuthNFlag is set. if (clientRoleHeader is not null) { - Assert.AreEqual( - expected: treatDevModeRequestAsAuthenticated, - actual: postMiddlewareContext.User.IsInRole(clientRoleHeader)); + Assert.AreEqual(expected: authenticateDevModeRequests, actual: postMiddlewareContext.User.IsInRole(clientRoleHeader)); } } #endregion diff --git a/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs b/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs index 96fc82df34..f512ca2b6e 100644 --- a/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs +++ b/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Security.Claims; using System.Text; using System.Text.Json; @@ -20,22 +21,22 @@ public static class AppServiceAuthentication /// Representation of authenticated user principal Http header /// injected by EasyAuth /// - public struct AppServiceClientPrincipal + public class AppServiceClientPrincipal { - public string Auth_typ { get; set; } - public string Name_typ { get; set; } - public string Role_typ { get; set; } - public IEnumerable Claims { get; set; } + public string? Auth_typ { get; set; } + public string? Name_typ { get; set; } + public string? Role_typ { get; set; } + public IEnumerable? Claims { get; set; } } /// /// Representation of authenticated user principal claims /// injected by EasyAuth /// - public struct AppServiceClaim + public class AppServiceClaim { - public string Typ { get; set; } - public string Val { get; set; } + public string? Typ { get; set; } + public string? Val { get; set; } } /// @@ -61,19 +62,27 @@ public struct AppServiceClaim string encodedPrincipalData = header[0]; byte[] decodedPrincpalData = Convert.FromBase64String(encodedPrincipalData); string json = Encoding.UTF8.GetString(decodedPrincpalData); - AppServiceClientPrincipal principal = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + AppServiceClientPrincipal? principal = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - identity = new(principal.Auth_typ, principal.Name_typ, principal.Role_typ); - - if (principal.Claims != null) + if (principal is not null && principal.Claims is not null && principal.Claims.Any()) { - foreach (AppServiceClaim claim in principal.Claims) + identity = new(principal.Auth_typ, principal.Name_typ, principal.Role_typ); + + // Copy all AppService token claims to .NET ClaimsIdentity object. + if (principal.Claims is not null && principal.Claims.Any()) { - identity.AddClaim(new Claim(type: claim.Typ, value: claim.Val)); + identity.AddClaims(principal.Claims + .Where(claim => claim.Typ is not null && claim.Val is not null) + .Select(claim => new Claim(type: claim.Typ!, value: claim.Val!)) + ); } } } - catch (Exception error) + catch (Exception error) when ( + error is JsonException || + error is ArgumentNullException || + error is NotSupportedException || + error is InvalidOperationException) { // Logging the parsing failure exception to the console, but not rethrowing // nor creating a DataApiBuilder exception because the authentication handler diff --git a/src/Service/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs b/src/Service/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs index 695e67643d..09e7c06268 100644 --- a/src/Service/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs +++ b/src/Service/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs @@ -59,10 +59,9 @@ public async Task InvokeAsync(HttpContext httpContext) // Write challenge response metadata (HTTP 401 Unauthorized response code // and www-authenticate headers) to the HTTP Context via JwtBearerHandler code // https://github.com/dotnet/aspnetcore/blob/3fe12b935c03138f76364dc877a7e069e254b5b2/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs#L217 - if (authNResult.Failure is not null) + if (authNResult.Failure is not null && !_runtimeConfigurationProvider.IsAuthenticatedDevModeRequest()) { await httpContext.ChallengeAsync(); - return; } diff --git a/src/Service/AuthenticationHelpers/EasyAuthAuthenticationDefaults.cs b/src/Service/AuthenticationHelpers/EasyAuthAuthenticationDefaults.cs index 362dca1880..acf036e248 100644 --- a/src/Service/AuthenticationHelpers/EasyAuthAuthenticationDefaults.cs +++ b/src/Service/AuthenticationHelpers/EasyAuthAuthenticationDefaults.cs @@ -9,5 +9,7 @@ public static class EasyAuthAuthenticationDefaults /// The default value used for StaticWebAppAuthenticationOptions.AuthenticationScheme. /// public const string AUTHENTICATIONSCHEME = "EasyAuthAuthentication"; + + public const string INVALID_PAYLOAD_ERROR = "Invalid EasyAuth Payload."; } } diff --git a/src/Service/AuthenticationHelpers/EasyAuthAuthenticationHandler.cs b/src/Service/AuthenticationHelpers/EasyAuthAuthenticationHandler.cs index fa9aaab361..55d97b0b59 100644 --- a/src/Service/AuthenticationHelpers/EasyAuthAuthenticationHandler.cs +++ b/src/Service/AuthenticationHelpers/EasyAuthAuthenticationHandler.cs @@ -38,12 +38,12 @@ ISystemClock clock } /// - /// Gets any authentication data for a request. When an EasyAuth header is present, - /// parses the header and authenticates the user within a ClaimsPrincipal object. + /// Attempts processing of a request's authentication metadata. + /// When an EasyAuth header is present, parses the header and authenticates the user within a ClaimsPrincipal object. /// The ClaimsPrincipal is a security principal usable by middleware to identify the /// authenticated user. /// - /// An authentication result to ASP.NET Core library authentication mechanisms + /// AuthenticatedResult (Fail, NoResult, Success). protected override Task HandleAuthenticateAsync() { if (Context.Request.Headers[AuthenticationConfig.CLIENT_PRINCIPAL_HEADER].Count > 0) @@ -55,13 +55,17 @@ protected override Task HandleAuthenticateAsync() _ => null }; - if (identity is null || HasOnlyAnonymousRole(identity.Claims)) + if (identity is null) { - // Either the token is invalid, Or the role is only anonymous, - // we don't terminate the pipeline since the request is - // always at least in the anonymous role. - // It means that anything that is exposed anonymously will still be visible. - // The role assigned to X-MS-API-ROLE will be anonymous. + return Task.FromResult(AuthenticateResult.Fail(failureMessage: EasyAuthAuthenticationDefaults.INVALID_PAYLOAD_ERROR)); + } + + if (HasOnlyAnonymousRole(identity.Claims)) + { + // When EasyAuth is properly configured, do not terminate the request pipeline + // since a request is always at least in the anonymous role. + // This result signals that authentication did not fail, though the request + // should be evaluated as unauthenticated. return Task.FromResult(AuthenticateResult.NoResult()); } @@ -77,12 +81,10 @@ protected override Task HandleAuthenticateAsync() } } - // Return no result when no EasyAuth header is present, - // because a request is always in anonymous role in EasyAuth - // This scenario is not possible when front loaded with EasyAuth - // since the X-MS-CLIENT-PRINCIPAL header will always be present in that case. - // This is applicable when engine is being tested without front loading with EasyAuth. - return Task.FromResult(AuthenticateResult.NoResult()); + // The EasyAuth (X-MS-CLIENT-PRINCIPAL) header will always be present in a properly configured environment + // for both anonymous and authenticated requests. + // Consequentially, authentication fails when EasyAuth header is not detected. + return Task.FromResult(AuthenticateResult.Fail(failureMessage: EasyAuthAuthenticationDefaults.INVALID_PAYLOAD_ERROR)); } /// @@ -90,7 +92,7 @@ protected override Task HandleAuthenticateAsync() /// /// /// - private static bool HasOnlyAnonymousRole(IEnumerable claims) + public static bool HasOnlyAnonymousRole(IEnumerable claims) { bool isUserAnonymousOnly = false; foreach (Claim claim in claims) From 6d001da56fd2f6a33c071029a6a62d1108ff4090 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Mon, 21 Nov 2022 17:55:07 -0800 Subject: [PATCH 2/8] Updated logic to handle anonymous requests -> x-ms-client-principal will not be present for anonymous requests. --- .../EasyAuthAuthenticationUnitTests.cs | 28 +++++++++++-------- .../EasyAuthAuthenticationHandler.cs | 9 +++--- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs index b626c54994..04eb051737 100644 --- a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs +++ b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs @@ -69,7 +69,7 @@ public async Task TestInvalidAppServiceEasyAuthToken(string easyAuthPayload) /// /// Ensures authentication fails when no EasyAuth header is present because - /// a correctly configured EasyAuth environment guarantees that ALL request types (anonymous and authenticated) + /// a correctly configured EasyAuth environment guarantees that only authenticated requests /// will contain an EasyAuth header. /// /// AppService/StaticWebApps @@ -80,7 +80,7 @@ public async Task TestMissingEasyAuthHeader(EasyAuthType easyAuthType) { HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(token: null, easyAuthType); - Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode); + Assert.AreEqual(expected: (int)HttpStatusCode.OK, actual: postMiddlewareContext.Response.StatusCode); Assert.IsNotNull(postMiddlewareContext.User.Identity); Assert.AreEqual(expected: false, actual: postMiddlewareContext.User.Identity.IsAuthenticated); } @@ -245,20 +245,20 @@ await SendRequestAndGetHttpContextState( } /// - /// - Ensures an invalid/no EasyAuth header payload results in HTTP 401 Unauthorized response - /// A correctly configured EasyAuth environment guarantees an EasyAuth payload for authenticated and anonymous requests. + /// - Ensures an invalid EasyAuth header payload results in HTTP 401 Unauthorized response + /// A correctly configured EasyAuth environment guarantees an EasyAuth payload for authenticated requests. + /// - Ensures a missing EasyAuth header results in HTTP OK and User.IsAuthenticated == false. /// - Also, validate that if other auth headers are present (Authorization Bearer token), that it is never considered /// when the runtime is configured for EasyAuth authentication. /// /// EasyAuth header value - /// [DataTestMethod] [DataRow("", DisplayName = "No EasyAuth payload -> 401 Unauthorized")] [DataRow("ey==", DisplayName = "Invalid EasyAuth payload -> 401 Unauthorized")] - [DataRow(null, DisplayName = "No EasyAuth header provided -> 401 Unauthorized")] + [DataRow(null, DisplayName = "No EasyAuth header provided -> 200 OK, Anonymous request")] [DataRow("", true, DisplayName = "No EasyAuth payload, include authorization header")] [DataRow("ey==", true, DisplayName = "Corrupt EasyAuth header value provided, include authorization header")] - [DataRow(null, true, DisplayName = "No EasyAuth header provided, include authorization header")] + [DataRow(null, true, DisplayName = "No EasyAuth header provided, include authorization header -> 200 OK, Anonymous request")] [TestMethod] public async Task TestInvalidStaticWebAppsEasyAuthToken(string easyAuthPayload, bool sendAuthorizationHeader = false) { @@ -271,8 +271,13 @@ await SendRequestAndGetHttpContextState( // Validate state of HttpContext after being processed by authentication middleware. Assert.IsNotNull(postMiddlewareContext.User.Identity); Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated); - Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode); - Assert.AreEqual(expected: string.Empty, actual: postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString()); + + // A missing EasyAuth header results in an anonymous request. + int expectedStatusCode = (easyAuthPayload is not null) ? (int)HttpStatusCode.Unauthorized : (int)HttpStatusCode.OK; + Assert.AreEqual(expected: expectedStatusCode, actual: postMiddlewareContext.Response.StatusCode); + string expectedResolvedRoleHeader = (easyAuthPayload is not null) ? string.Empty : AuthorizationResolver.ROLE_ANONYMOUS; + string actualResolvedRoleHeader = postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); + Assert.AreEqual(expected: expectedResolvedRoleHeader, actual: actualResolvedRoleHeader, ignoreCase: true); } /// @@ -285,7 +290,7 @@ await SendRequestAndGetHttpContextState( /// Expected value of X-MS-API-ROLE header. /// Value of X-MS-API-ROLE header specified in request. [DataTestMethod] - [DataRow(false, null, null, DisplayName = "Disabled, no auth headers -> 401 Unauthorized")] + [DataRow(false, "Anonymous", null, DisplayName = "Disabled, no auth headers -> 401 Unauthorized")] [DataRow(true, "Authenticated", null, DisplayName = "Enabled, no auth headers -> clientRoleHeader set to (authenticated)")] [DataRow(true, "author", "author", DisplayName = "Enabled, honor the clientRoleHeader (author)")] [DataRow(true, "Anonymous", "Anonymous", DisplayName = "Enabled, honor the clientRoleHeader (anonymous)")] @@ -303,8 +308,7 @@ await SendRequestAndGetHttpContextState( // Validate state of HttpContext after being processed by authentication middleware. Assert.IsNotNull(postMiddlewareContext.User.Identity); - int expectedStatusCode = authenticateDevModeRequests ? (int)HttpStatusCode.OK : (int)HttpStatusCode.Unauthorized; - Assert.AreEqual(expected: expectedStatusCode, actual: postMiddlewareContext.Response.StatusCode); + Assert.AreEqual(expected: (int)HttpStatusCode.OK, actual: postMiddlewareContext.Response.StatusCode); string resolvedRoleHeader = postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); if (!string.IsNullOrWhiteSpace(resolvedRoleHeader)) diff --git a/src/Service/AuthenticationHelpers/EasyAuthAuthenticationHandler.cs b/src/Service/AuthenticationHelpers/EasyAuthAuthenticationHandler.cs index 55d97b0b59..9783934eb8 100644 --- a/src/Service/AuthenticationHelpers/EasyAuthAuthenticationHandler.cs +++ b/src/Service/AuthenticationHelpers/EasyAuthAuthenticationHandler.cs @@ -55,6 +55,8 @@ protected override Task HandleAuthenticateAsync() _ => null }; + // If identity is null when the X-MS-CLIENT-PRINCIPAL header is present, + // the header payload failed to parse -> Authentication Failure. if (identity is null) { return Task.FromResult(AuthenticateResult.Fail(failureMessage: EasyAuthAuthenticationDefaults.INVALID_PAYLOAD_ERROR)); @@ -81,10 +83,9 @@ protected override Task HandleAuthenticateAsync() } } - // The EasyAuth (X-MS-CLIENT-PRINCIPAL) header will always be present in a properly configured environment - // for both anonymous and authenticated requests. - // Consequentially, authentication fails when EasyAuth header is not detected. - return Task.FromResult(AuthenticateResult.Fail(failureMessage: EasyAuthAuthenticationDefaults.INVALID_PAYLOAD_ERROR)); + // The EasyAuth (X-MS-CLIENT-PRINCIPAL) header will only be present in a properly configured environment + // for authenticated requests and not anonymous requests. + return Task.FromResult(AuthenticateResult.NoResult()); } /// From 2ec11d1314186d0f93c06bdd428a353f201c9e20 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Mon, 28 Nov 2022 12:27:59 -0800 Subject: [PATCH 3/8] update comment spelling. --- .../Authentication/EasyAuthAuthenticationUnitTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs index 04eb051737..77941b15ab 100644 --- a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs +++ b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs @@ -51,7 +51,7 @@ public async Task TestValidAppServiceEasyAuthToken(bool sendAuthorizationHeader) } /// - /// Invalid AppService EasyAuth payloads elicit a 401 Unauthorized responsed, indicating + /// Invalid AppService EasyAuth payloads elicit a 401 Unauthorized response, indicating /// failed authentication. /// [DataTestMethod] From 768e4fd37cf5ecdf22a63367f2b84f14180fea1d Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Mon, 28 Nov 2022 13:38:14 -0800 Subject: [PATCH 4/8] Add tests and enhance parsing conditionals for AppServiceClientPrincipal --- src/Service.Tests/AuthTestHelper.cs | 14 +++++---- .../EasyAuthAuthenticationUnitTests.cs | 6 ++-- .../AppServiceAuthentication.cs | 30 ++++++++++++++----- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/Service.Tests/AuthTestHelper.cs b/src/Service.Tests/AuthTestHelper.cs index 3803ef57f1..6ab821ea9c 100644 --- a/src/Service.Tests/AuthTestHelper.cs +++ b/src/Service.Tests/AuthTestHelper.cs @@ -14,24 +14,26 @@ internal static class AuthTestHelper /// Creates a mocked EasyAuth token, namely, the value of the header injected by EasyAuth. /// /// A Base64 encoded string of a serialized EasyAuthClientPrincipal object - public static string CreateAppServiceEasyAuthToken() + public static string CreateAppServiceEasyAuthToken( + string nameType = ClaimTypes.Name, + string roleType = ClaimTypes.Role) { AppServiceClaim emailClaim = new() { Val = "apple@contoso.com", - Typ = ClaimTypes.Upn + Typ = nameType }; AppServiceClaim roleClaimAnonymous = new() { Val = "Anonymous", - Typ = ClaimTypes.Role + Typ = roleType }; AppServiceClaim roleClaimAuthenticated = new() { Val = "Authenticated", - Typ = ClaimTypes.Role + Typ = roleType }; List claims = new() @@ -44,9 +46,9 @@ public static string CreateAppServiceEasyAuthToken() AppServiceClientPrincipal token = new() { Auth_typ = "aad", - Name_typ = "Apple Banana", + Name_typ = nameType, Claims = claims, - Role_typ = ClaimTypes.Role + Role_typ = roleType }; string serializedToken = JsonSerializer.Serialize(value: token); diff --git a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs index 77941b15ab..a76de00b1d 100644 --- a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs +++ b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs @@ -51,13 +51,15 @@ public async Task TestValidAppServiceEasyAuthToken(bool sendAuthorizationHeader) } /// - /// Invalid AppService EasyAuth payloads elicit a 401 Unauthorized response, indicating - /// failed authentication. + /// Invalid AppService EasyAuth payloads elicit a 401 Unauthorized response, indicating failed authentication. /// [DataTestMethod] [DataRow("", DisplayName = "Empty JSON not serializable to AppServiceClientPrincipal")] [DataRow("eyJtZXNzYWdlIjogImhlbGxvIHdvcmxkIn0=", DisplayName = "JSON not serializable to AppServiceClientPrincipal")] [DataRow("aGVsbG8sIHdvcmxkIQ==", DisplayName = "Non-JSON Base64 encoded string not serializable to AppServiceClientPrincipal")] + [DataRow("eyJhdXRoX3R5cCI6IiIsIm5hbWVfdHlwIjoidW5pcXVlX25hbWUiLCJyb2xlX3R5cCI6InJvbGVzIn0=", DisplayName = "Missing value for property Auth_typ")] + [DataRow("eyJhdXRoX3R5cCI6IG51bGwsIm5hbWVfdHlwIjoidW5pcXVlX25hbWUiLCJyb2xlX3R5cCI6InJvbGVzIn0=", DisplayName = "Null value for property Auth_typ")] + [DataRow("eyJhdXRoX3R5cCI6ICIiLCJuYW1lX3R5cCI6InVuaXF1ZV9uYW1lIiwicm9sZV90eXAiOiJyb2xlcyJ9", DisplayName = "Empty value for property Auth_typ")] public async Task TestInvalidAppServiceEasyAuthToken(string easyAuthPayload) { HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(token: easyAuthPayload, EasyAuthType.AppService); diff --git a/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs b/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs index f512ca2b6e..f803e7d39c 100644 --- a/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs +++ b/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Azure.DataApiBuilder.Config; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -23,9 +24,23 @@ public static class AppServiceAuthentication /// public class AppServiceClientPrincipal { - public string? Auth_typ { get; set; } + /// + /// The type of authentication used, unauthenticated request when null. + /// + public string Auth_typ { get; set; } = null!; + /// + /// The Claim.Type used when obtaining the value of . + /// + /// public string? Name_typ { get; set; } + /// + /// The Claim.Type used when performing logic for . + /// + /// public string? Role_typ { get; set; } + /// + /// Collection of claims optionally present. + /// public IEnumerable? Claims { get; set; } } @@ -40,10 +55,9 @@ public class AppServiceClaim } /// - /// Create ClaimsIdentity object from EasyAuth - /// injected x-ms-client-principal injected header, - /// the value is a base64 encoded custom JWT injected by EasyAuth - /// as a result of validating a bearer token. + /// Create ClaimsIdentity object from EasyAuth injected x-ms-client-principal injected header, + /// the value is a base64 encoded custom JWT injected by EasyAuth as a result of validating a bearer token. + /// If present, copies all AppService token claims to .NET ClaimsIdentity object. /// /// Request's Http Context /// @@ -64,11 +78,13 @@ public class AppServiceClaim string json = Encoding.UTF8.GetString(decodedPrincpalData); AppServiceClientPrincipal? principal = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - if (principal is not null && principal.Claims is not null && principal.Claims.Any()) + if (!string.IsNullOrEmpty(principal?.Auth_typ)) { + // When Name_typ and Role_type are null, ClaimsIdentity contructor uses default values. + // Auth_typ must not be null or empty for ClaimsIdentity.IsAuthenticated() to be true. + // Whitespace is not a requirement per: https://learn.microsoft.com/en-us/dotnet/api/system.security.claims.claimsidentity.isauthenticated?view=net-6.0#remarks identity = new(principal.Auth_typ, principal.Name_typ, principal.Role_typ); - // Copy all AppService token claims to .NET ClaimsIdentity object. if (principal.Claims is not null && principal.Claims.Any()) { identity.AddClaims(principal.Claims From d5043c65742afa6cddf9f07a028e9f8137c72504 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Mon, 28 Nov 2022 14:35:43 -0800 Subject: [PATCH 5/8] Added unit tests and comments. --- src/Service.Tests/AuthTestHelper.cs | 49 +++++++++++--- .../EasyAuthAuthenticationUnitTests.cs | 65 +++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/Service.Tests/AuthTestHelper.cs b/src/Service.Tests/AuthTestHelper.cs index 6ab821ea9c..7e473e8027 100644 --- a/src/Service.Tests/AuthTestHelper.cs +++ b/src/Service.Tests/AuthTestHelper.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Security.Claims; @@ -13,42 +14,74 @@ internal static class AuthTestHelper /// /// Creates a mocked EasyAuth token, namely, the value of the header injected by EasyAuth. /// + /// Defines the ClaimType of the claim used for the return value of Identity.Name + /// Defines the ClaimType of the claim used for the return value of ClaimsPrincpal.IsInRole(roleName) /// A Base64 encoded string of a serialized EasyAuthClientPrincipal object + /// + /// public static string CreateAppServiceEasyAuthToken( - string nameType = ClaimTypes.Name, - string roleType = ClaimTypes.Role) + string? nameClaimType = ClaimTypes.Name, + string? roleClaimType = ClaimTypes.Role) { AppServiceClaim emailClaim = new() { Val = "apple@contoso.com", - Typ = nameType + Typ = ClaimTypes.Upn }; AppServiceClaim roleClaimAnonymous = new() { Val = "Anonymous", - Typ = roleType + Typ = roleClaimType }; AppServiceClaim roleClaimAuthenticated = new() { Val = "Authenticated", - Typ = roleType + Typ = roleClaimType + }; + + AppServiceClaim roleClaimShortNameClaimType = new() + { + Val = "RoleShortClaimType", + Typ = "roles" + }; + + AppServiceClaim roleClaimUriClaimType = new() + { + Val = "RoleUriClaimType", + Typ = ClaimTypes.Role + }; + + AppServiceClaim nameShortClaimType = new() + { + Val = "NameShortClaimType", + Typ = "unique_name" + }; + + AppServiceClaim nameUriClaimType = new() + { + Val = "NameUriClaimType", + Typ = ClaimTypes.Name }; List claims = new() { emailClaim, roleClaimAnonymous, - roleClaimAuthenticated + roleClaimAuthenticated, + roleClaimShortNameClaimType, + roleClaimUriClaimType, + nameShortClaimType, + nameUriClaimType }; AppServiceClientPrincipal token = new() { Auth_typ = "aad", - Name_typ = nameType, + Name_typ = nameClaimType, Claims = claims, - Role_typ = roleType + Role_typ = roleClaimType }; string serializedToken = JsonSerializer.Serialize(value: token); diff --git a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs index a76de00b1d..db9fd7f010 100644 --- a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs +++ b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs @@ -1,7 +1,9 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Net; +using System.Security.Claims; using System.Threading.Tasks; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Authorization; @@ -40,6 +42,7 @@ public async Task TestValidAppServiceEasyAuthToken(bool sendAuthorizationHeader) generatedToken, EasyAuthType.AppService, sendAuthorizationHeader); + Assert.IsNotNull(postMiddlewareContext.User.Identity); Assert.IsTrue(postMiddlewareContext.User.Identity.IsAuthenticated); Assert.AreEqual(expected: (int)HttpStatusCode.OK, @@ -50,6 +53,68 @@ public async Task TestValidAppServiceEasyAuthToken(bool sendAuthorizationHeader) ignoreCase: true); } + /// + /// Tests that the value returned by Identity.Name comes from the claim of type nameClaimType, + /// which reflects correct AppService EasyAuth parsing into a ClaimsIdentity object used to + /// create a ClaimsPrincipal object. + /// + /// Expected name to be returned by Identity.Name + /// Defines the ClaimType of the claim used for the return value of Identity.Name + /// + /// + [DataTestMethod] + [DataRow("NameShortClaimType", "unique_name", DisplayName = "Identity.Name from custom claim name type")] + [DataRow("NameUriClaimType", ClaimTypes.Name, DisplayName = "Identity.Name from URI claim name type")] + [DataRow("NameUriClaimType", null, DisplayName = "Identity.Name from default URI claim name type")] + public async Task TestNameClaimTypeAppServiceEasyAuthToken(string? name, string? nameClaimType) + { + // Generated token has the following relevant claims: + // Type | Value + // NameShortClaimType | unique_name + // NameUriClaimType | ClaimTypes.Name + string generatedToken = AuthTestHelper.CreateAppServiceEasyAuthToken(nameClaimType: nameClaimType); + HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState( + generatedToken, + EasyAuthType.AppService + ); + + Assert.IsNotNull(postMiddlewareContext.User.Identity); + Assert.IsTrue(postMiddlewareContext.User.Identity.IsAuthenticated); + Assert.AreEqual(expected: name, actual: postMiddlewareContext.User.Identity.Name); + } + + /// + /// Tests that the value returned by ClaimsPrincipal.IsInRole comes from the claim of type roleClaimType, + /// which reflects correct AppService EasyAuth parsing into a ClaimsIdentity object used to + /// create a ClaimsPrincipal object. + /// + /// User expected to be in role. + /// Name of role to check in role membership query. + /// Defines the ClaimType of the claim used for the return value of ClaimsPrincpal.IsInRole(roleName) + /// + /// + [DataTestMethod] + [DataRow(true, "RoleShortClaimType", "roles")] + [DataRow(false, "RoleUriClaimType", "roles")] + [DataRow(false, "RoleShortClaimType", ClaimTypes.Role)] + [DataRow(true, "RoleUriClaimType", ClaimTypes.Role)] + public async Task TestRoleClaimTypeAppServiceEasyAuthToken(bool isInRole, string roleName, string roleClaimType) + { + // Generated token has the following relevant claims: + // Type | Value + // RoleShortClaimType | roles + // RoleUriClaimType | ClaimTypes.Role + string generatedToken = AuthTestHelper.CreateAppServiceEasyAuthToken(roleClaimType: roleClaimType); + HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState( + generatedToken, + EasyAuthType.AppService + ); + + Assert.IsNotNull(postMiddlewareContext.User.Identity); + Assert.IsTrue(postMiddlewareContext.User.Identity.IsAuthenticated); + Assert.AreEqual(expected: isInRole, actual: postMiddlewareContext.User.IsInRole(roleName)); + } + /// /// Invalid AppService EasyAuth payloads elicit a 401 Unauthorized response, indicating failed authentication. /// From a5f742414d44c6a87a6679a7ed2134e5b99266bf Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Mon, 28 Nov 2022 14:37:17 -0800 Subject: [PATCH 6/8] Fix warnings. --- src/Service.Tests/AuthTestHelper.cs | 4 ++-- .../Authentication/EasyAuthAuthenticationUnitTests.cs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Service.Tests/AuthTestHelper.cs b/src/Service.Tests/AuthTestHelper.cs index 7e473e8027..cd1e91422c 100644 --- a/src/Service.Tests/AuthTestHelper.cs +++ b/src/Service.Tests/AuthTestHelper.cs @@ -97,8 +97,8 @@ public static string CreateAppServiceEasyAuthToken( /// A Base64 encoded string of a serialized StaticWebAppsClientPrincipal object public static string CreateStaticWebAppsEasyAuthToken( bool addAuthenticated = true, - string specificRole = null, - IEnumerable claims = null) + string? specificRole = null, + IEnumerable? claims = null) { // The anonymous role is present in all requests sent to Static Web Apps or AppService endpoints. List roles = new() diff --git a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs index db9fd7f010..366a7ef26f 100644 --- a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs +++ b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs @@ -1,5 +1,4 @@ #nullable enable -using System; using System.Collections.Generic; using System.Linq; using System.Net; From fcd04bafddfeb5c73e5796a65e1cdccade718ba5 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Mon, 28 Nov 2022 14:52:43 -0800 Subject: [PATCH 7/8] remove usings and insert whitespace --- .../AuthenticationHelpers/AppServiceAuthentication.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs b/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs index f803e7d39c..9794a2b537 100644 --- a/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs +++ b/src/Service/AuthenticationHelpers/AppServiceAuthentication.cs @@ -4,7 +4,6 @@ using System.Security.Claims; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using Azure.DataApiBuilder.Config; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -80,9 +79,9 @@ public class AppServiceClaim if (!string.IsNullOrEmpty(principal?.Auth_typ)) { - // When Name_typ and Role_type are null, ClaimsIdentity contructor uses default values. - // Auth_typ must not be null or empty for ClaimsIdentity.IsAuthenticated() to be true. - // Whitespace is not a requirement per: https://learn.microsoft.com/en-us/dotnet/api/system.security.claims.claimsidentity.isauthenticated?view=net-6.0#remarks + // When Name_typ and Role_type are null, ClaimsIdentity contructor uses default values. + // Auth_typ must not be null or empty for ClaimsIdentity.IsAuthenticated() to be true. + // Whitespace is not a requirement per: https://learn.microsoft.com/en-us/dotnet/api/system.security.claims.claimsidentity.isauthenticated?view=net-6.0#remarks identity = new(principal.Auth_typ, principal.Name_typ, principal.Role_typ); if (principal.Claims is not null && principal.Claims.Any()) From b2fe941ef2468025d20a3bfecf0df49bc11ebc51 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 30 Nov 2022 09:17:54 -0800 Subject: [PATCH 8/8] Change Assert.AreEqual to Assert.IsFalse --- .../Authentication/EasyAuthAuthenticationUnitTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs index 366a7ef26f..fbf29323bc 100644 --- a/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs +++ b/src/Service.Tests/Authentication/EasyAuthAuthenticationUnitTests.cs @@ -148,7 +148,7 @@ public async Task TestMissingEasyAuthHeader(EasyAuthType easyAuthType) Assert.AreEqual(expected: (int)HttpStatusCode.OK, actual: postMiddlewareContext.Response.StatusCode); Assert.IsNotNull(postMiddlewareContext.User.Identity); - Assert.AreEqual(expected: false, actual: postMiddlewareContext.User.Identity.IsAuthenticated); + Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated); } ///