diff --git a/WebJobs.Script.sln b/WebJobs.Script.sln index 083137896f..397ee5a525 100644 --- a/WebJobs.Script.sln +++ b/WebJobs.Script.sln @@ -275,6 +275,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TimerTrigger", "TimerTrigge sample\Node\TimerTrigger\index.js = sample\Node\TimerTrigger\index.js EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HttpTrigger-Identities", "HttpTrigger-Identities", "{DCBE49B4-CE2C-4D44-8DD9-2B771343E1C6}" + ProjectSection(SolutionItems) = preProject + sample\CSharp\HttpTrigger-Identities\function.json = sample\CSharp\HttpTrigger-Identities\function.json + sample\CSharp\HttpTrigger-Identities\run.csx = sample\CSharp\HttpTrigger-Identities\run.csx + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HttpTrigger-Identities", "HttpTrigger-Identities", "{909C3F6B-1B36-498F-8707-C6CC54CD2242}" + ProjectSection(SolutionItems) = preProject + sample\Node\HttpTrigger-Identities\function.json = sample\Node\HttpTrigger-Identities\function.json + sample\Node\HttpTrigger-Identities\index.js = sample\Node\HttpTrigger-Identities\index.js + EndProjectSection +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution test\WebJobs.Script.Tests.Shared\WebJobs.Script.Tests.Shared.projitems*{35c9ccb7-d8b6-4161-bb0d-bcfa7c6dcffb}*SharedItemsImports = 13 @@ -374,6 +386,8 @@ Global {1A6107CB-6295-4388-9C63-F21A78D5137E} = {9D87C796-7914-4A43-B843-579562393E10} {A0AAA922-2AFA-4418-BB85-CE7602394273} = {9D87C796-7914-4A43-B843-579562393E10} {B773D1BC-495A-45CD-82CF-393A698FD958} = {9D87C796-7914-4A43-B843-579562393E10} + {DCBE49B4-CE2C-4D44-8DD9-2B771343E1C6} = {34506711-9D66-41EF-BBA1-9A9DC1140209} + {909C3F6B-1B36-498F-8707-C6CC54CD2242} = {9D87C796-7914-4A43-B843-579562393E10} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {85400884-5FFD-4C27-A571-58CB3C8CAAC5} diff --git a/sample/CSharp/HttpTrigger-Identities/function.json b/sample/CSharp/HttpTrigger-Identities/function.json new file mode 100644 index 0000000000..3866c521e9 --- /dev/null +++ b/sample/CSharp/HttpTrigger-Identities/function.json @@ -0,0 +1,15 @@ +{ + "bindings": [ + { + "type": "httpTrigger", + "name": "req", + "direction": "in", + "methods": [ "get" ] + }, + { + "type": "http", + "name": "$return", + "direction": "out" + } + ] +} diff --git a/sample/CSharp/HttpTrigger-Identities/run.csx b/sample/CSharp/HttpTrigger-Identities/run.csx new file mode 100644 index 0000000000..cc06c6cfa6 --- /dev/null +++ b/sample/CSharp/HttpTrigger-Identities/run.csx @@ -0,0 +1,32 @@ +using System.Linq; +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; + +public static IActionResult Run(HttpRequest req, TraceWriter log, ClaimsPrincipal principal) +{ + log.Info("C# HTTP trigger function processed a request."); + + string[] identityStrings = principal.Identities.Select(GetIdentityString).ToArray(); + + return new OkObjectResult(string.Join(";", identityStrings)); +} + +private static string GetIdentityString(ClaimsIdentity identity) +{ + var userIdClaim = identity.FindFirst(ClaimTypes.NameIdentifier); + if (userIdClaim != null) + { + // user identity + var userNameClaim = identity.FindFirst(ClaimTypes.Name); + return $"Identity: ({identity.AuthenticationType}, {userNameClaim.Value}, {userIdClaim.Value})"; + } + else + { + // key based identity + var authLevelClaim = identity.FindFirst("http://schemas.microsoft.com/2017/07/functions/claims/authlevel"); + var keyIdClaim = identity.FindFirst("http://schemas.microsoft.com/2017/07/functions/claims/keyid"); + return $"Identity: ({identity.AuthenticationType}, {authLevelClaim.Value}, {keyIdClaim.Value})"; + } +} \ No newline at end of file diff --git a/sample/Node/HttpTrigger-Identities/function.json b/sample/Node/HttpTrigger-Identities/function.json new file mode 100644 index 0000000000..3866c521e9 --- /dev/null +++ b/sample/Node/HttpTrigger-Identities/function.json @@ -0,0 +1,15 @@ +{ + "bindings": [ + { + "type": "httpTrigger", + "name": "req", + "direction": "in", + "methods": [ "get" ] + }, + { + "type": "http", + "name": "$return", + "direction": "out" + } + ] +} diff --git a/sample/Node/HttpTrigger-Identities/index.js b/sample/Node/HttpTrigger-Identities/index.js new file mode 100644 index 0000000000..24a7568709 --- /dev/null +++ b/sample/Node/HttpTrigger-Identities/index.js @@ -0,0 +1,30 @@ +module.exports = function (context, req) { + console.dir(context.bindingData.identities); + var identityString = context.bindingData.identities.map(GetIdentityString).join(";"); + + var res = { + status: 200, + body: identityString, + headers: { + 'Content-Type': 'text/plain' + } + }; + + context.done(null, res); +}; + +function GetIdentityString(identity) { + var nameIdentifierType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"; + var userIdClaim = identity.claims.find(claim => claim.type === nameIdentifierType); + if (userIdClaim) { + // user claim + var nameType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"; + var userNameClaim = identity.claims.find(claim => claim.type === nameType); + return `Identity: (${identity.authenticationType}, ${userNameClaim.value}, ${userIdClaim.value})`; + } else { + // key based identity + var authLevelClaim = identity.claims.find(claim => claim.type === "http://schemas.microsoft.com/2017/07/functions/claims/authlevel"); + var keyIdClaim = identity.claims.find(claim => claim.type === "http://schemas.microsoft.com/2017/07/functions/claims/keyid"); + return `Identity: (${identity.authenticationType}, ${authLevelClaim.value}, ${keyIdClaim.value})`; + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Security/Authentication/Keys/AuthenticationLevelHandler.cs b/src/WebJobs.Script.WebHost/Security/Authentication/Keys/AuthenticationLevelHandler.cs index 130a217991..9541df8410 100644 --- a/src/WebJobs.Script.WebHost/Security/Authentication/Keys/AuthenticationLevelHandler.cs +++ b/src/WebJobs.Script.WebHost/Security/Authentication/Keys/AuthenticationLevelHandler.cs @@ -23,6 +23,7 @@ internal class AuthenticationLevelHandler : AuthenticationHandler options, @@ -30,10 +31,12 @@ public AuthenticationLevelHandler( UrlEncoder encoder, IDataProtectionProvider dataProtection, ISystemClock clock, - ISecretManagerProvider secretManagerProvider) + ISecretManagerProvider secretManagerProvider, + IEnvironment environment) : base(options, logger, encoder, clock) { _secretManagerProvider = secretManagerProvider; + _isEasyAuthEnabled = environment.IsEasyAuthEnabled(); } protected override async Task HandleAuthenticateAsync() @@ -53,8 +56,19 @@ protected override async Task HandleAuthenticateAsync() claims.Add(new Claim(SecurityConstants.AuthLevelKeyNameClaimType, name)); } - var identity = new ClaimsIdentity(claims, AuthLevelAuthenticationDefaults.AuthenticationScheme); - return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)); + List claimsIdentities = new List(); + var keyIdentity = new ClaimsIdentity(claims, AuthLevelAuthenticationDefaults.AuthenticationScheme); + if (_isEasyAuthEnabled) + { + ClaimsIdentity easyAuthIdentity = Context.Request.GetAppServiceIdentity(); + if (easyAuthIdentity != null) + { + claimsIdentities.Add(easyAuthIdentity); + } + } + claimsIdentities.Add(keyIdentity); + + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(claimsIdentities), Scheme.Name)); } else { diff --git a/src/WebJobs.Script/Environment/EnvironmentExtensions.cs b/src/WebJobs.Script/Environment/EnvironmentExtensions.cs index ed0eaf2c33..76a4a29467 100644 --- a/src/WebJobs.Script/Environment/EnvironmentExtensions.cs +++ b/src/WebJobs.Script/Environment/EnvironmentExtensions.cs @@ -32,6 +32,12 @@ public static bool IsPlaceholderModeEnabled(this IEnvironment environment) return environment.GetEnvironmentVariable(AzureWebsitePlaceholderMode) == "1"; } + public static bool IsEasyAuthEnabled(this IEnvironment environment) + { + bool.TryParse(environment.GetEnvironmentVariable(EnvironmentSettingNames.EasyAuthEnabled), out bool isEasyAuthEnabled); + return isEasyAuthEnabled; + } + public static bool IsRunningAsHostedSiteExtension(this IEnvironment environment) { if (environment.IsAppServiceEnvironment()) diff --git a/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs b/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs index 90f0f9f5d1..ccfefab540 100644 --- a/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs +++ b/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs @@ -28,6 +28,7 @@ public static class EnvironmentSettingNames public const string ConsoleLoggingDisabled = "CONSOLE_LOGGING_DISABLED"; public const string SkipSslValidation = "SCM_SKIP_SSL_VALIDATION"; public const string EnvironmentNameKey = "AZURE_FUNCTIONS_ENVIRONMENT"; + public const string EasyAuthEnabled = "WEBSITE_AUTH_ENABLED"; /// /// Environment variable dynamically set by the platform when it is safe to diff --git a/src/WebJobs.Script/WebJobs.Script.csproj b/src/WebJobs.Script/WebJobs.Script.csproj index 6efc990e48..e8a95470ce 100644 --- a/src/WebJobs.Script/WebJobs.Script.csproj +++ b/src/WebJobs.Script/WebJobs.Script.csproj @@ -39,7 +39,7 @@ - + diff --git a/test/TestFunctions/TestFunctions.csproj b/test/TestFunctions/TestFunctions.csproj index ceb64bb586..c619e94938 100644 --- a/test/TestFunctions/TestFunctions.csproj +++ b/test/TestFunctions/TestFunctions.csproj @@ -17,7 +17,7 @@ - + diff --git a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_CSharp.cs b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_CSharp.cs index f790fa3acd..8139b270c7 100644 --- a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_CSharp.cs +++ b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_CSharp.cs @@ -293,7 +293,7 @@ public async Task ListFunctions_Succeeds() var response = await _fixture.Host.HttpClient.SendAsync(request); var metadata = (await response.Content.ReadAsAsync>()).ToArray(); - Assert.Equal(15, metadata.Length); + Assert.Equal(16, metadata.Length); var function = metadata.Single(p => p.Name == "HttpTrigger-CustomRoute"); Assert.Equal("https://localhost/csharp/products/{category:alpha?}/{id:int?}/{extra?}", function.InvokeUrlTemplate.ToString()); @@ -610,6 +610,55 @@ public async Task HttpTriggerWithObject_Post_Succeeds() } } + [Fact] + public async Task HttpTrigger_Identities_Succeeds() + { + var vars = new Dictionary + { + { LanguageWorkerConstants.FunctionWorkerRuntimeSettingName, LanguageWorkerConstants.DotNetLanguageWorkerName}, + { "WEBSITE_AUTH_ENABLED", "TRUE"} + }; + using (var env = new TestScopedEnvironmentVariable(vars)) + { + string functionKey = await _fixture.Host.GetFunctionSecretAsync("HttpTrigger-Identities"); + string uri = $"api/httptrigger-identities?code={functionKey}"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); + + MockEasyAuth(request, "facebook", "Connor McMahon", "10241897674253170"); + + HttpResponseMessage response = await this._fixture.Host.HttpClient.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + string responseContent = await response.Content.ReadAsStringAsync(); + string[] identityStrings = StripBookendQuotations(responseContent).Split(';'); + Assert.Equal("Identity: (facebook, Connor McMahon, 10241897674253170)", identityStrings[0]); + Assert.Equal("Identity: (WebJobsAuthLevel, Function, Key1)", identityStrings[1]); + } + } + + [Fact] + public async Task HttpTrigger_Identities_BlocksSpoofedEasyAuthIdentity() + { + var vars = new Dictionary + { + { LanguageWorkerConstants.FunctionWorkerRuntimeSettingName, LanguageWorkerConstants.DotNetLanguageWorkerName}, + { "WEBSITE_AUTH_ENABLED", "FALSE"} + }; + using (var env = new TestScopedEnvironmentVariable(vars)) + { + string functionKey = await _fixture.Host.GetFunctionSecretAsync("HttpTrigger-Identities"); + string uri = $"api/httptrigger-identities?code={functionKey}"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); + + MockEasyAuth(request, "facebook", "Connor McMahon", "10241897674253170"); + + HttpResponseMessage response = await this._fixture.Host.HttpClient.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + string responseContent = await response.Content.ReadAsStringAsync(); + string identityString = StripBookendQuotations(responseContent); + Assert.Equal("Identity: (WebJobsAuthLevel, Function, Key1)", identityString); + } + } + private async Task GetHostStatusAsync() { string uri = "admin/host/status"; @@ -657,6 +706,40 @@ private async Task RestartHostAsync() return await _fixture.Host.HttpClient.SendAsync(request); } + internal static string StripBookendQuotations(string response) + { + if (response.StartsWith("\"") && response.EndsWith("\"")) + { + return response.Substring(1, response.Length - 2); + } + return response; + } + + internal static void MockEasyAuth(HttpRequestMessage request, string provider, string name, string id) + { + string userIdentityJson = @"{ + ""auth_typ"": """ + provider + @""", + ""claims"": [ + { + ""typ"": ""http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"", + ""val"": """ + name + @""" + }, + { + ""typ"": ""http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"", + ""val"": """ + name + @""" + }, + { + ""typ"": ""http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"", + ""val"": """ + id + @""" + } + ], + ""name_typ"": ""http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"", + ""role_typ"": ""http://schemas.microsoft.com/ws/2008/06/identity/claims/role"" +}"; + string easyAuthHeaderValue = Convert.ToBase64String(Encoding.UTF8.GetBytes(userIdentityJson)); + request.Headers.Add("x-ms-client-principal", easyAuthHeaderValue); + } + public class TestFixture : EndToEndTestFixture { static TestFixture() @@ -680,6 +763,7 @@ public override void ConfigureJobHost(IWebJobsBuilder webJobsBuilder) "HttpTrigger-Compat", "HttpTrigger-CustomRoute", "HttpTrigger-POCO", + "HttpTrigger-Identities", "HttpTriggerWithObject", "ManualTrigger" }; diff --git a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_Node.cs b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_Node.cs index 24258337e3..a8c337c97d 100644 --- a/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_Node.cs +++ b/test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_Node.cs @@ -449,6 +449,31 @@ public async Task HttpTrigger_Disabled_SucceedsWithAdminKey() Assert.Equal("Hello World!", body); } + [Fact] + public async Task HttpTrigger_Identities_Succeeds() + { + var vars = new Dictionary + { + { "WEBSITE_AUTH_ENABLED", "TRUE"} + }; + using (var env = new TestScopedEnvironmentVariable(vars)) + { + string id = Guid.NewGuid().ToString(); + string functionKey = await _fixture.Host.GetFunctionSecretAsync("HttpTrigger-Identities"); + string uri = $"api/httptrigger-identities?code={functionKey}"; + + var request = new HttpRequestMessage(HttpMethod.Get, uri); + SamplesEndToEndTests_CSharp.MockEasyAuth(request, "facebook", "Connor McMahon", "10241897674253170"); + + HttpResponseMessage response = await this._fixture.Host.HttpClient.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + string responseContent = await response.Content.ReadAsStringAsync(); + string[] identityStrings = SamplesEndToEndTests_CSharp.StripBookendQuotations(responseContent).Split(';'); + Assert.Equal("Identity: (facebook, Connor McMahon, 10241897674253170)", identityStrings[0]); + Assert.Equal("Identity: (WebJobsAuthLevel, Function, Key1)", identityStrings[1]); + } + } + public class TestFixture : EndToEndTestFixture { static TestFixture() @@ -486,6 +511,7 @@ public override void ConfigureJobHost(IWebJobsBuilder webJobsBuilder) "HttpTrigger", "HttpTrigger-CustomRoute-Get", "HttpTrigger-Disabled", + "HttpTrigger-Identities", "ManualTrigger" }; }); diff --git a/test/WebJobs.Script.Tests/FunctionMetadataManagerTests.cs b/test/WebJobs.Script.Tests/FunctionMetadataManagerTests.cs index ad4a0ce9d5..7eec367958 100644 --- a/test/WebJobs.Script.Tests/FunctionMetadataManagerTests.cs +++ b/test/WebJobs.Script.Tests/FunctionMetadataManagerTests.cs @@ -229,7 +229,7 @@ public void ReadFunctionMetadata_Succeeds() var functionErrors = new Dictionary>(); var functionDirectories = Directory.EnumerateDirectories(functionsPath); var metadata = FunctionMetadataManager.ReadFunctionsMetadata(functionDirectories, null, TestHelpers.GetTestWorkerConfigs(), NullLogger.Instance, functionErrors); - Assert.Equal(17, metadata.Count); + Assert.Equal(18, metadata.Count); } [Theory]