From 453a2ca2a4dc1afd342dc083366e14367ed7df80 Mon Sep 17 00:00:00 2001 From: Joonas Westlin Date: Thu, 31 May 2018 20:45:45 +0300 Subject: [PATCH] Added native client app --- .../AzureAdScopeClaimTransformation.cs | 29 ++++++ .../Authorization/Constants.cs | 7 ++ .../Authorization/ScopeRequirement.cs | 19 ---- .../Authorization/ScopeRequirementHandler.cs | 29 ------ .../Controllers/TodosController.cs | 38 ++++--- .../Joonasw.AzureAdApiSample.Api.csproj | 10 +- Joonasw.AzureAdApiSample.Api/Program.cs | 7 +- .../Properties/launchSettings.json | 3 +- Joonasw.AzureAdApiSample.Api/Startup.cs | 8 +- .../App.config | 13 +++ ...zureAdApiSample.ConsoleNativeClient.csproj | 66 +++++++++++++ .../Program.cs | 23 +++++ .../Properties/AssemblyInfo.cs | 36 +++++++ .../TodoApiClient.cs | 99 +++++++++++++++++++ .../TodoItem.cs | 11 +++ .../packages.config | 5 + Joonasw.AzureAdApiSample.sln | 8 +- 17 files changed, 325 insertions(+), 86 deletions(-) create mode 100644 Joonasw.AzureAdApiSample.Api/Authorization/AzureAdScopeClaimTransformation.cs create mode 100644 Joonasw.AzureAdApiSample.Api/Authorization/Constants.cs delete mode 100644 Joonasw.AzureAdApiSample.Api/Authorization/ScopeRequirement.cs delete mode 100644 Joonasw.AzureAdApiSample.Api/Authorization/ScopeRequirementHandler.cs create mode 100644 Joonasw.AzureAdApiSample.ConsoleNativeClient/App.config create mode 100644 Joonasw.AzureAdApiSample.ConsoleNativeClient/Joonasw.AzureAdApiSample.ConsoleNativeClient.csproj create mode 100644 Joonasw.AzureAdApiSample.ConsoleNativeClient/Program.cs create mode 100644 Joonasw.AzureAdApiSample.ConsoleNativeClient/Properties/AssemblyInfo.cs create mode 100644 Joonasw.AzureAdApiSample.ConsoleNativeClient/TodoApiClient.cs create mode 100644 Joonasw.AzureAdApiSample.ConsoleNativeClient/TodoItem.cs create mode 100644 Joonasw.AzureAdApiSample.ConsoleNativeClient/packages.config diff --git a/Joonasw.AzureAdApiSample.Api/Authorization/AzureAdScopeClaimTransformation.cs b/Joonasw.AzureAdApiSample.Api/Authorization/AzureAdScopeClaimTransformation.cs new file mode 100644 index 0000000..0edcd48 --- /dev/null +++ b/Joonasw.AzureAdApiSample.Api/Authorization/AzureAdScopeClaimTransformation.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; + +namespace Joonasw.AzureAdApiSample.Api.Authorization +{ + public class AzureAdScopeClaimTransformation : IClaimsTransformation + { + public Task TransformAsync(ClaimsPrincipal principal) + { + var scopeClaims = principal.FindAll(Constants.ScopeClaimType).ToList(); + if (scopeClaims.Count != 1 || !scopeClaims[0].Value.Contains(' ')) + { + // Caller has no scopes or has multiple scopes (already split) + // or they have only one scope + return Task.FromResult(principal); + } + + Claim claim = scopeClaims[0]; + string[] scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + IEnumerable claims = scopes.Select(s => new Claim(Constants.ScopeClaimType, s)); + + return Task.FromResult(new ClaimsPrincipal(new ClaimsIdentity(principal.Identity, claims))); + } + } +} diff --git a/Joonasw.AzureAdApiSample.Api/Authorization/Constants.cs b/Joonasw.AzureAdApiSample.Api/Authorization/Constants.cs new file mode 100644 index 0000000..58061fd --- /dev/null +++ b/Joonasw.AzureAdApiSample.Api/Authorization/Constants.cs @@ -0,0 +1,7 @@ +namespace Joonasw.AzureAdApiSample.Api.Authorization +{ + public static class Constants + { + public const string ScopeClaimType = "http://schemas.microsoft.com/identity/claims/scope"; + } +} diff --git a/Joonasw.AzureAdApiSample.Api/Authorization/ScopeRequirement.cs b/Joonasw.AzureAdApiSample.Api/Authorization/ScopeRequirement.cs deleted file mode 100644 index 8f901e3..0000000 --- a/Joonasw.AzureAdApiSample.Api/Authorization/ScopeRequirement.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Joonasw.AzureAdApiSample.Api.Authorization -{ - public class ScopeRequirement : IAuthorizationRequirement - { - public ScopeRequirement(string requiredScopeValue) - { - if (string.IsNullOrEmpty(requiredScopeValue)) - { - throw new System.ArgumentException("message", nameof(requiredScopeValue)); - } - - RequiredScopeValue = requiredScopeValue; - } - - public string RequiredScopeValue { get; } - } -} diff --git a/Joonasw.AzureAdApiSample.Api/Authorization/ScopeRequirementHandler.cs b/Joonasw.AzureAdApiSample.Api/Authorization/ScopeRequirementHandler.cs deleted file mode 100644 index e31c7fb..0000000 --- a/Joonasw.AzureAdApiSample.Api/Authorization/ScopeRequirementHandler.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; - -namespace Joonasw.AzureAdApiSample.Api.Authorization -{ - public class ScopeRequirementHandler : AuthorizationHandler - { - private const string ClaimScope = "http://schemas.microsoft.com/identity/claims/scope"; - - protected override Task HandleRequirementAsync( - AuthorizationHandlerContext context, - ScopeRequirement requirement) - { - string[] userScopes = - context.User.FindFirstValue(ClaimScope)?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? - new string[0]; - if (userScopes.Contains(requirement.RequiredScopeValue)) - { - context.Succeed(requirement); - } - - return Task.CompletedTask; - } - } -} diff --git a/Joonasw.AzureAdApiSample.Api/Controllers/TodosController.cs b/Joonasw.AzureAdApiSample.Api/Controllers/TodosController.cs index 6ec54df..d7cdeae 100644 --- a/Joonasw.AzureAdApiSample.Api/Controllers/TodosController.cs +++ b/Joonasw.AzureAdApiSample.Api/Controllers/TodosController.cs @@ -6,37 +6,33 @@ namespace Joonasw.AzureAdApiSample.Api.Controllers { + [ApiController] [Route("api/[controller]")] - public class TodosController : Controller + public class TodosController : ControllerBase { // In-memory data-store for testing. - private readonly List _todoItems; - - public TodosController() + private static readonly List TodoItems = new List { - _todoItems = new List + new TodoItem { - new TodoItem - { - Id = Guid.NewGuid(), - Text = "Implement authentication", - IsDone = true - } - }; - } + Id = Guid.NewGuid(), + Text = "Implement authentication", + IsDone = true + } + }; // GET api/todos [HttpGet] public IActionResult Get() { - return Ok(_todoItems); + return Ok(TodoItems); } // GET api/todos/guid-value [HttpGet("{id}")] public IActionResult Get(Guid id) { - var item = _todoItems.FirstOrDefault(i => i.Id == id); + TodoItem item = TodoItems.FirstOrDefault(i => i.Id == id); if(item == null) { return NotFound(); @@ -51,7 +47,7 @@ public IActionResult Post([FromBody]TodoItem model) { model.Id = Guid.NewGuid(); - _todoItems.Add(model); + TodoItems.Add(model); return CreatedAtAction(nameof(Get), new{id = model.Id}, model); } @@ -61,14 +57,14 @@ public IActionResult Put(Guid id, [FromBody]TodoItem model) { model.Id = id; - var item = _todoItems.FirstOrDefault(i => i.Id == id); + TodoItem item = TodoItems.FirstOrDefault(i => i.Id == id); if(item == null) { return NotFound(); } - _todoItems.Remove(item); - _todoItems.Add(model); + TodoItems.Remove(item); + TodoItems.Add(model); return NoContent(); } @@ -77,10 +73,10 @@ public IActionResult Put(Guid id, [FromBody]TodoItem model) [HttpDelete("{id}")] public void Delete(Guid id) { - var item = _todoItems.FirstOrDefault(i => i.Id == id); + TodoItem item = TodoItems.FirstOrDefault(i => i.Id == id); if(item != null) { - _todoItems.Remove(item); + TodoItems.Remove(item); } } } diff --git a/Joonasw.AzureAdApiSample.Api/Joonasw.AzureAdApiSample.Api.csproj b/Joonasw.AzureAdApiSample.Api/Joonasw.AzureAdApiSample.Api.csproj index d97fb14..08ec6bc 100644 --- a/Joonasw.AzureAdApiSample.Api/Joonasw.AzureAdApiSample.Api.csproj +++ b/Joonasw.AzureAdApiSample.Api/Joonasw.AzureAdApiSample.Api.csproj @@ -1,7 +1,7 @@ - + - netcoreapp2.0 + netcoreapp2.1 @@ -9,11 +9,7 @@ - - - - - + diff --git a/Joonasw.AzureAdApiSample.Api/Program.cs b/Joonasw.AzureAdApiSample.Api/Program.cs index 54c02fa..7e598f3 100644 --- a/Joonasw.AzureAdApiSample.Api/Program.cs +++ b/Joonasw.AzureAdApiSample.Api/Program.cs @@ -7,12 +7,11 @@ public class Program { public static void Main(string[] args) { - BuildWebHost(args).Run(); + CreateWebHostBuilder(args).Build().Run(); } - public static IWebHost BuildWebHost(string[] args) => + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) - .UseStartup() - .Build(); + .UseStartup(); } } diff --git a/Joonasw.AzureAdApiSample.Api/Properties/launchSettings.json b/Joonasw.AzureAdApiSample.Api/Properties/launchSettings.json index a306674..b1fdaf6 100644 --- a/Joonasw.AzureAdApiSample.Api/Properties/launchSettings.json +++ b/Joonasw.AzureAdApiSample.Api/Properties/launchSettings.json @@ -10,7 +10,6 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, "launchUrl": "api/values", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -26,4 +25,4 @@ "applicationUrl": "http://localhost:2673/" } } -} +} \ No newline at end of file diff --git a/Joonasw.AzureAdApiSample.Api/Startup.cs b/Joonasw.AzureAdApiSample.Api/Startup.cs index 7eb7736..9009220 100644 --- a/Joonasw.AzureAdApiSample.Api/Startup.cs +++ b/Joonasw.AzureAdApiSample.Api/Startup.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using Joonasw.AzureAdApiSample.Api.Authorization; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -24,13 +26,13 @@ public void ConfigureServices(IServiceCollection services) services.AddMvc(o => { o.Filters.Add(new AuthorizeFilter("default")); - }); + }).SetCompatibilityVersion(CompatibilityVersion.Version_2_1); services.AddAuthorization(o => { o.AddPolicy("default", policy => { - policy.Requirements.Add(new ScopeRequirement("user_impersonation")); + policy.RequireClaim(Constants.ScopeClaimType, "user_impersonation"); }); }); @@ -51,7 +53,7 @@ public void ConfigureServices(IServiceCollection services) } }; }); - services.AddSingleton(); + services.AddSingleton(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) diff --git a/Joonasw.AzureAdApiSample.ConsoleNativeClient/App.config b/Joonasw.AzureAdApiSample.ConsoleNativeClient/App.config new file mode 100644 index 0000000..66a3aef --- /dev/null +++ b/Joonasw.AzureAdApiSample.ConsoleNativeClient/App.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Joonasw.AzureAdApiSample.ConsoleNativeClient/Joonasw.AzureAdApiSample.ConsoleNativeClient.csproj b/Joonasw.AzureAdApiSample.ConsoleNativeClient/Joonasw.AzureAdApiSample.ConsoleNativeClient.csproj new file mode 100644 index 0000000..f67ecf6 --- /dev/null +++ b/Joonasw.AzureAdApiSample.ConsoleNativeClient/Joonasw.AzureAdApiSample.ConsoleNativeClient.csproj @@ -0,0 +1,66 @@ + + + + + Debug + AnyCPU + {D3973CD4-097F-4332-8D10-39B3A1F83F47} + Exe + Joonasw.AzureAdApiSample.ConsoleNativeClient + Joonasw.AzureAdApiSample.ConsoleNativeClient + v4.6.1 + 512 + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + 7.2 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\Microsoft.IdentityModel.Clients.ActiveDirectory.3.19.6\lib\net45\Microsoft.IdentityModel.Clients.ActiveDirectory.dll + + + ..\packages\Microsoft.IdentityModel.Clients.ActiveDirectory.3.19.6\lib\net45\Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll + + + ..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Joonasw.AzureAdApiSample.ConsoleNativeClient/Program.cs b/Joonasw.AzureAdApiSample.ConsoleNativeClient/Program.cs new file mode 100644 index 0000000..4cb0120 --- /dev/null +++ b/Joonasw.AzureAdApiSample.ConsoleNativeClient/Program.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace Joonasw.AzureAdApiSample.ConsoleNativeClient +{ + class Program + { + static async Task Main(string[] args) + { + var todoApiClient = new TodoApiClient(); + await todoApiClient.ListTodosAsync(); + Guid id = await todoApiClient.CreateTodoAsync(new TodoItem + { + Text = "Test from Console Native app", + IsDone = false + }); + await todoApiClient.ListTodosAsync(); + await todoApiClient.DeleteTodoAsync(id); + await todoApiClient.ListTodosAsync(); + Console.ReadLine(); + } + } +} diff --git a/Joonasw.AzureAdApiSample.ConsoleNativeClient/Properties/AssemblyInfo.cs b/Joonasw.AzureAdApiSample.ConsoleNativeClient/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..20e60c0 --- /dev/null +++ b/Joonasw.AzureAdApiSample.ConsoleNativeClient/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Joonasw.AzureAdApiSample.ConsoleNativeClient")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Joonasw.AzureAdApiSample.ConsoleNativeClient")] +[assembly: AssemblyCopyright("Copyright © 2018")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("d3973cd4-097f-4332-8d10-39b3a1f83f47")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Joonasw.AzureAdApiSample.ConsoleNativeClient/TodoApiClient.cs b/Joonasw.AzureAdApiSample.ConsoleNativeClient/TodoApiClient.cs new file mode 100644 index 0000000..f1d7ba4 --- /dev/null +++ b/Joonasw.AzureAdApiSample.ConsoleNativeClient/TodoApiClient.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Clients.ActiveDirectory; +using Newtonsoft.Json; + +namespace Joonasw.AzureAdApiSample.ConsoleNativeClient +{ + public class TodoApiClient + { + private static readonly string Authority = ConfigurationManager.AppSettings["AzureAd:Authority"]; + private static readonly string ApiResourceUri = ConfigurationManager.AppSettings["AzureAd:ApiResourceUri"]; + private static readonly string ClientId = ConfigurationManager.AppSettings["AzureAd:ClientId"]; + private static readonly Uri RedirectUri = new Uri(ConfigurationManager.AppSettings["AzureAd:RedirectUri"]); + private static readonly string ApiBaseUrl = ConfigurationManager.AppSettings["AzureAd:ApiBaseUrl"]; + private static readonly HttpClient Client = new HttpClient(); + + public async Task ListTodosAsync() + { + using (var req = new HttpRequestMessage(HttpMethod.Get, $"{ApiBaseUrl}/api/todos")) + { + string accessToken = await GetAccessTokenAsync(); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using (HttpResponseMessage res = await Client.SendAsync(req)) + { + res.EnsureSuccessStatusCode(); + string json = await res.Content.ReadAsStringAsync(); + List todos = JsonConvert.DeserializeObject>(json); + ListTodosOnConsole(todos); + } + } + } + + private void ListTodosOnConsole(List todos) + { + Console.WriteLine($"---Todos list--- ({todos.Count} items)"); + foreach (TodoItem todo in todos) + { + Console.WriteLine($"{todo.Text}: {(todo.IsDone ? "Done" : "Not done")} ({todo.Id})"); + } + } + + public async Task CreateTodoAsync(TodoItem todoItem) + { + Console.WriteLine("---Create todo item---"); + using (var req = new HttpRequestMessage(HttpMethod.Post, $"{ApiBaseUrl}/api/todos")) + { + string accessToken = await GetAccessTokenAsync(); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + req.Content = new StringContent( + JsonConvert.SerializeObject(todoItem), + Encoding.UTF8, + "application/json"); + + using (HttpResponseMessage res = await Client.SendAsync(req)) + { + res.EnsureSuccessStatusCode(); + string json = await res.Content.ReadAsStringAsync(); + TodoItem createdTodo = JsonConvert.DeserializeObject(json); + Console.WriteLine($"Created: {createdTodo.Text}: {(createdTodo.IsDone ? "Done" : "Not done")} ({createdTodo.Id})"); + return createdTodo.Id; + } + } + } + + public async Task DeleteTodoAsync(Guid id) + { + Console.WriteLine("---Delete todo item---"); + using (var req = new HttpRequestMessage(HttpMethod.Delete, $"{ApiBaseUrl}/api/todos/{id}")) + { + string accessToken = await GetAccessTokenAsync(); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using (HttpResponseMessage res = await Client.SendAsync(req)) + { + res.EnsureSuccessStatusCode(); + Console.WriteLine($"Todo item deleted with id {id}"); + } + } + } + + private async Task GetAccessTokenAsync() + { + var context = new AuthenticationContext(Authority); + + var result = await context.AcquireTokenAsync( + ApiResourceUri, + ClientId, + RedirectUri, + new PlatformParameters(PromptBehavior.Auto)); + return result.AccessToken; + } + } +} \ No newline at end of file diff --git a/Joonasw.AzureAdApiSample.ConsoleNativeClient/TodoItem.cs b/Joonasw.AzureAdApiSample.ConsoleNativeClient/TodoItem.cs new file mode 100644 index 0000000..a062291 --- /dev/null +++ b/Joonasw.AzureAdApiSample.ConsoleNativeClient/TodoItem.cs @@ -0,0 +1,11 @@ +using System; + +namespace Joonasw.AzureAdApiSample.ConsoleNativeClient +{ + public class TodoItem + { + public Guid Id { get; set; } + public string Text { get; set; } + public bool IsDone { get; set; } + } +} diff --git a/Joonasw.AzureAdApiSample.ConsoleNativeClient/packages.config b/Joonasw.AzureAdApiSample.ConsoleNativeClient/packages.config new file mode 100644 index 0000000..207f389 --- /dev/null +++ b/Joonasw.AzureAdApiSample.ConsoleNativeClient/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Joonasw.AzureAdApiSample.sln b/Joonasw.AzureAdApiSample.sln index 64672cd..12bc4c7 100644 --- a/Joonasw.AzureAdApiSample.sln +++ b/Joonasw.AzureAdApiSample.sln @@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.27705.2000 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joonasw.AzureAdApiSample.Api", "Joonasw.AzureAdApiSample.Api\Joonasw.AzureAdApiSample.Api.csproj", "{7F42DC02-1742-4592-B8A5-3F786AB842CF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Joonasw.AzureAdApiSample.Api", "Joonasw.AzureAdApiSample.Api\Joonasw.AzureAdApiSample.Api.csproj", "{7F42DC02-1742-4592-B8A5-3F786AB842CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joonasw.AzureAdApiSample.ConsoleNativeClient", "Joonasw.AzureAdApiSample.ConsoleNativeClient\Joonasw.AzureAdApiSample.ConsoleNativeClient.csproj", "{D3973CD4-097F-4332-8D10-39B3A1F83F47}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +17,10 @@ Global {7F42DC02-1742-4592-B8A5-3F786AB842CF}.Debug|Any CPU.Build.0 = Debug|Any CPU {7F42DC02-1742-4592-B8A5-3F786AB842CF}.Release|Any CPU.ActiveCfg = Release|Any CPU {7F42DC02-1742-4592-B8A5-3F786AB842CF}.Release|Any CPU.Build.0 = Release|Any CPU + {D3973CD4-097F-4332-8D10-39B3A1F83F47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3973CD4-097F-4332-8D10-39B3A1F83F47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3973CD4-097F-4332-8D10-39B3A1F83F47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3973CD4-097F-4332-8D10-39B3A1F83F47}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE