diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/Readme.md b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/Readme.md new file mode 100644 index 0000000..b1a33fc --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/Readme.md @@ -0,0 +1,107 @@ +# Service-to-Service Authentication Using a JWT + +## Introduction + +This sample shows how a client can authenticate with a CoreWCF service using JWT-based authentication. It includes two services; one that uses Azure Active Directory as the identity provider, and another that makes authenticated requests to the first service. The same pattern will apply to other JWT-based authentication providers or to end-user rather than service-to-service authentication. + +## What is a JWT + +[JSON Web Token](https://en.wikipedia.org/wiki/JSON_Web_Token) ([rfc 7519](https://www.rfc-editor.org/rfc/rfc7519)) is a way to support federated authentication and authorization of http requests. The client makes a call to an authentication server passing credentials and identifying information about the resource they wish to access. If authentication succeeds and the resource is authorized, they are handed back a signed token which includes information about the resource and rights. The client then passes that token to the service, which can then verify its integrity and trust the claims it specifies. + +JWT is an open standard supported by multiple server stacks, clients, code languages and identity providers. + +## Azure Active Directory + +This particular sample is designed for use with Azure Active Directory (AAD) as the identity provider. However, the code that is specific to AAD is limited and another identity provider can easily be handled by replacing: +- In the client, the code and settings to authenticate with AAD and retrieve the token +- In the server, the code and settings to use AAD as the JWT provider for ASP.NET Core + +If you wish to run the samples as-is, you will need to register the applications with an existing Azure AD tenant or create a new one. This sample is based on one of the [Azure AD samples](https://github.com/Azure-Samples/active-directory-dotnetcore-daemon-v2/tree/master/4-Call-OwnApi-Pop). Instructions on how to register apps are included in its README. + +## Projects + +The sample consists of 2 projects around the concept of a word game: +- TileService is a WCF Core server app that exposes an http endpoint that will return a set of letter tiles that could be used in a word game. +- WordGame is an ASP.NET Core Web API endpoint that will call the TileService and return the results as a JSON blob. + +These projects were chosen so that there is minimal sample code that is not related to the role of the authentication flow. + +## Managing Secrets + +In any kind of scenario involving service-to-service authentication, you will invariably need to deal with some form of shared secret, be it a string or a client certificate. These should **NOT** be included in code or be added to source control. At runtime, the secrets should come from some form of secure storage. .NET makes this easier to manage through the configuration API and built-in overlays: +- At design time, the [User Secrets](https://docs.microsoft.com/aspnet/core/security/app-secrets) feature will create a configuration overlay file that can be used to store secrets. In Visual Studio, right-click on the project and choose *Manage User Secrets* to create and open the overlay file. Client Secrets should be stored in this file during development. +- At runtime, environment variables will overlay config properties. Connection strings and secrets can be placed there and then accessed via configuration. Hosting environments have support for safely storing secrets and supplying them at runtime. For instance, here are instructions for doing this in [Azure App Service](https://docs.microsoft.com/azure/app-service/configure-common?tabs=portal#configure-app-settings) or [Azure Container Apps](https://docs.microsoft.com/azure/container-apps/manage-secrets?tabs=azure-cli) + +The appsettings.json files in the projects include the names of the config parameters that are required for the AAD authentication. They can be stored in User Secrets for additional security during development. + +# Using JWT with WCF Services + +The WS-* specifications which define the SOAP protocol and form the basis for WCF were developed long before JWT came onto the scene as the preferred form of web authentication. For this reason the WCF client APIs don't include direct support for JWT-based authentication or authorization. However, JWT is implemented over http by supplying the token as a base64-encoded string as the `Authorization` header. These samples add that header and validate it as part of the service call. + +## Azure AD configuration +Within the Azure AD tenant used for authentication, the following need to be configured. See this [README](https://github.com/Azure-Samples/active-directory-dotnetcore-daemon-v2/tree/master/4-Call-OwnApi-Pop) for instructions. +- App registration for the Tile Service + - With an App role of `AccessTileBag` +- App registration of the Word Game app + - With a client secret to identify the app + - Grant the API Permission of `AccessTileBag` for the Tile Service API + +## TileService (Server app) +TileService is a CoreWCF Server app. It supports one API defined in *ITileService.cs* for `DrawTiles`. + +The app uses the Azure AD integration with ASP.NET Core to enable and perform JWT Authentication. This is done through: +- Adding Nuget references for `Microsoft.Identity.Web` and `Microsoft.VisualStudio.Azure.Containers.Tools.Targets` + +- Including the ASP.NET Core and Azure AD SDK support for JWT with: + +``` c# +// ASP.NET Core Authentication +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(builder.Configuration); +``` + +- Adding Authentication and Authorization to the ASP.NET Core pipeline with + +``` c# +app.UseAuthentication(); +app.UseAuthorization(); +``` + +- Attributing the service call with the claims that need to be present + +``` c# +[AuthorizeRole("AccessTileBag")] +public IList DrawTiles(int count) +{ + ... +} +``` +- Configuring the parameters for the Azure SDK to validate the JWT in appsettings.json + +``` json + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "ClientId": "[Enter the Client Id of the service (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]", + "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]", + "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]" + } +``` + +## WordGame (Service Client) + +The service client is included in a WebAPI app that exposes a simple HTTP GET endpoint to fetch the tiles. + +To make the WCF service calls, the app uses a Service Reference client wrapper, generated from the WSDL from the Tile Service. + +The key parts of the application are: + +- At startup, it calls `getAzAdJwtBlob`, which + - Reads AAD properties from configuration (appsettings.json) and User Secrets + - Calls AAD to get a JWT for this specific service +- Exposes a WebAPI at `/getTiles` that will make the WCF service call and return the response as JSON + - Exposes a WebAPI at `/` which redirects to /getTiles with a count parameter +- The `getTiles` function which + - Uses an `OperationContextScope` to add an http `Authorization` header with the JWT as the value + - Calls the Tile Service API using the generated wrapper class + +The only AAD specific code in this sample is included in the getAzAdJWTBlob function. The same pattern can be followed to retrieve a JWT from other authentication providers, or for performing user authentication, etc. \ No newline at end of file diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/ServiceToServiceJWTSample.sln b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/ServiceToServiceJWTSample.sln new file mode 100644 index 0000000..c53b781 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/ServiceToServiceJWTSample.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.32728.343 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TileService", "TileService\TileService.csproj", "{8B29DBB7-9AEF-4809-ADB7-A376B16A2C84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WordGame", "WordGame\WordGame.csproj", "{D9DFC022-7E02-4595-908C-BB0A5683CDF5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8B29DBB7-9AEF-4809-ADB7-A376B16A2C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B29DBB7-9AEF-4809-ADB7-A376B16A2C84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B29DBB7-9AEF-4809-ADB7-A376B16A2C84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B29DBB7-9AEF-4809-ADB7-A376B16A2C84}.Release|Any CPU.Build.0 = Release|Any CPU + {D9DFC022-7E02-4595-908C-BB0A5683CDF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9DFC022-7E02-4595-908C-BB0A5683CDF5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9DFC022-7E02-4595-908C-BB0A5683CDF5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9DFC022-7E02-4595-908C-BB0A5683CDF5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B915AA32-40C3-40DD-B7BC-A30DD28CA42B} + EndGlobalSection +EndGlobal diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/.dockerignore b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/.dockerignore new file mode 100644 index 0000000..3729ff0 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Dockerfile b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Dockerfile new file mode 100644 index 0000000..23ad846 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Dockerfile @@ -0,0 +1,22 @@ +#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /src +COPY ["TileService.csproj", "."] +RUN dotnet restore "./TileService.csproj" +COPY . . +WORKDIR "/src/." +RUN dotnet build "TileService.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "TileService.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "TileService.dll"] \ No newline at end of file diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/ITileService.cs b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/ITileService.cs new file mode 100644 index 0000000..6f8ac12 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/ITileService.cs @@ -0,0 +1,128 @@ +using CoreWCF; +using System; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Identity.Web.Resource; +using System.Web.Services.Description; + +namespace TileService +{ + [ServiceContract] + public interface ITileService + { + [OperationContract] + IList DrawTiles(int count); + } + + public partial class TileBag : ITileService + { + private IList _gameTiles; + private int _tileCount; + private Random _rnd; + private ILogger _logger; + private bool _isDev = false; + + // Parameterized constructor will be called by Dependency Injection + public TileBag(ILogger logger, IWebHostEnvironment env) + { + _logger = logger; + if (env.IsDevelopment()) _isDev = true; + _rnd = new Random(); + _gameTiles = initTileCollection(); + } + + + [AuthorizeRole("AccessTileBag")] + + public IList DrawTiles(int count, [Injected] HttpContext ctx) + { + if (_isDev) + { + foreach (var claim in ctx.User.Claims) + { + _logger.LogDebug("Claims {claim}", claim.ToString()); + } + foreach (var h in ctx.Request.Headers) + { + _logger.LogDebug("Request Header {name}={value}", h.Key, h.Value); + } + } + if (count > 0) + { + var tiles = new List(); + for (var i = 0; i < count; i++) + { + var index = _rnd.Next(_tileCount); + var t_offset = 0; + foreach (var t in _gameTiles) + { + if (t_offset + t.Weight > index) + { + tiles.Add(t); + break; + } + t_offset += t.Weight; + } + } + return tiles; + } + throw new ArgumentException("DrawTiles needs to be given a positive number of tiles to return"); + } + + + private IList initTileCollection() + { + var tiles = new List(); + tiles.Add(new GameTile('A', 1, 9)); + tiles.Add(new GameTile('B', 3, 2)); + tiles.Add(new GameTile('C', 3, 2)); + tiles.Add(new GameTile('D', 2, 4)); + tiles.Add(new GameTile('E', 1, 12)); + tiles.Add(new GameTile('F', 4, 2)); + tiles.Add(new GameTile('G', 2, 3)); + tiles.Add(new GameTile('H', 4, 2)); + tiles.Add(new GameTile('I', 1, 9)); + tiles.Add(new GameTile('J', 7, 1)); + tiles.Add(new GameTile('K', 5, 1)); + tiles.Add(new GameTile('L', 1, 4)); + tiles.Add(new GameTile('M', 3, 2)); + tiles.Add(new GameTile('N', 1, 6)); + tiles.Add(new GameTile('O', 1, 8)); + tiles.Add(new GameTile('P', 3, 2)); + tiles.Add(new GameTile('Q', 10, 1)); + tiles.Add(new GameTile('R', 1, 9)); + tiles.Add(new GameTile('S', 1, 4)); + tiles.Add(new GameTile('T', 1, 6)); + tiles.Add(new GameTile('U', 1, 4)); + tiles.Add(new GameTile('V', 4, 2)); + tiles.Add(new GameTile('W', 1, 9)); + tiles.Add(new GameTile('X', 8, 1)); + tiles.Add(new GameTile('Y', 4, 2)); + tiles.Add(new GameTile('Z', 10, 1)); + tiles.Add(new GameTile('\0', 0, 2)); + + _tileCount = (from t in tiles + select t.Weight).Sum(); + return tiles; + } + } + + [DataContract] + public class GameTile + { + [DataMember] + public char Letter { get; init; } + [DataMember] + public int Score { get; init; } + [DataMember] + public int Weight { get; init; } + + public GameTile(char letter, int score, int weight) + { + Letter = letter; + Score = score; + Weight = weight; + } + } +} diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Program.cs b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Program.cs new file mode 100644 index 0000000..4cdfcfe --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Program.cs @@ -0,0 +1,41 @@ +using Microsoft.Identity.Web; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Web.Services.Description; + +var builder = WebApplication.CreateBuilder(); + +// CoreWCF Services +builder.Services.AddServiceModelServices(); +builder.Services.AddServiceModelMetadata(); +builder.Services.AddSingleton(); + +//Register the `TileBag` class with Dependency Injection, based on it being a single instance +builder.Services.AddSingleton(); + +// ASP.NET Core Authentication +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(builder.Configuration); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + // Should be turned off for production, but will show user info as part of logging + Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; +} + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseServiceModel(serviceBuilder => +{ + serviceBuilder.AddService(); + serviceBuilder.AddServiceEndpoint(new BasicHttpBinding(BasicHttpSecurityMode.Transport), "https://host/TileService"); + var serviceMetadataBehavior = app.Services.GetRequiredService(); + serviceMetadataBehavior.HttpsGetEnabled = true; +}); + +app.MapGet("/", () => "Error incorrect path: Use /TileService to access the service"); + +app.Run(); diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Properties/launchSettings.json b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Properties/launchSettings.json new file mode 100644 index 0000000..49d4e97 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "profiles": { + "TileService": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:7121;http://localhost:5035" + }, + "Docker": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "publishAllPorts": true, + "useSSL": true + } + } +} \ No newline at end of file diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/TileService.csproj b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/TileService.csproj new file mode 100644 index 0000000..7d200c7 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/TileService.csproj @@ -0,0 +1,24 @@ + + + net6.0 + enable + true + dfcd5381-edd3-4aa9-ac84-877a2a131db4 + Linux + . + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/appsettings.Development.json b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/appsettings.json b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/appsettings.json new file mode 100644 index 0000000..99026e4 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug" + /*, + "Default": "Information", + "Microsoft.AspNetCore": "Warning" */ + } + }, + "AllowedHosts": "*", + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "ClientId": "[Enter the Client Id of the service (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]", + "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]", + "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]" + } +} diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Connected Services/ServiceReference1/ConnectedService.json b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Connected Services/ServiceReference1/ConnectedService.json new file mode 100644 index 0000000..0c98a1c --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Connected Services/ServiceReference1/ConnectedService.json @@ -0,0 +1,16 @@ +{ + "ExtendedData": { + "inputs": [ + "https://localhost:7121/TileService" + ], + "collectionTypes": [ + "System.Array", + "System.Collections.Generic.Dictionary`2" + ], + "namespaceMappings": [ + "*, ServiceReference1" + ], + "targetFramework": "net6.0", + "typeReuseMode": "All" + } +} \ No newline at end of file diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Connected Services/ServiceReference1/Reference.cs b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Connected Services/ServiceReference1/Reference.cs new file mode 100644 index 0000000..ea7aad8 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Connected Services/ServiceReference1/Reference.cs @@ -0,0 +1,181 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace ServiceReference1 +{ + using System.Runtime.Serialization; + + + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Tools.ServiceModel.Svcutil", "2.0.3")] + [System.Runtime.Serialization.DataContractAttribute(Name="GameTile", Namespace="http://schemas.datacontract.org/2004/07/TileService")] + public partial class GameTile : object + { + + private char LetterField; + + private int ScoreField; + + private int WeightField; + + [System.Runtime.Serialization.DataMemberAttribute()] + public char Letter + { + get + { + return this.LetterField; + } + set + { + this.LetterField = value; + } + } + + [System.Runtime.Serialization.DataMemberAttribute()] + public int Score + { + get + { + return this.ScoreField; + } + set + { + this.ScoreField = value; + } + } + + [System.Runtime.Serialization.DataMemberAttribute()] + public int Weight + { + get + { + return this.WeightField; + } + set + { + this.WeightField = value; + } + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Tools.ServiceModel.Svcutil", "2.0.3")] + [System.ServiceModel.ServiceContractAttribute(ConfigurationName="ServiceReference1.ITileService")] + public interface ITileService + { + + [System.ServiceModel.OperationContractAttribute(Action="http://tempuri.org/ITileService/DrawTiles", ReplyAction="http://tempuri.org/ITileService/DrawTilesResponse")] + System.Threading.Tasks.Task DrawTilesAsync(int count); + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Tools.ServiceModel.Svcutil", "2.0.3")] + public interface ITileServiceChannel : ServiceReference1.ITileService, System.ServiceModel.IClientChannel + { + } + + [System.Diagnostics.DebuggerStepThroughAttribute()] + [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Tools.ServiceModel.Svcutil", "2.0.3")] + public partial class TileServiceClient : System.ServiceModel.ClientBase, ServiceReference1.ITileService + { + + /// + /// Implement this partial method to configure the service endpoint. + /// + /// The endpoint to configure + /// The client credentials + static partial void ConfigureEndpoint(System.ServiceModel.Description.ServiceEndpoint serviceEndpoint, System.ServiceModel.Description.ClientCredentials clientCredentials); + + public TileServiceClient() : + base(TileServiceClient.GetDefaultBinding(), TileServiceClient.GetDefaultEndpointAddress()) + { + this.Endpoint.Name = EndpointConfiguration.BasicHttpBinding_ITileService.ToString(); + ConfigureEndpoint(this.Endpoint, this.ClientCredentials); + } + + public TileServiceClient(EndpointConfiguration endpointConfiguration) : + base(TileServiceClient.GetBindingForEndpoint(endpointConfiguration), TileServiceClient.GetEndpointAddress(endpointConfiguration)) + { + this.Endpoint.Name = endpointConfiguration.ToString(); + ConfigureEndpoint(this.Endpoint, this.ClientCredentials); + } + + public TileServiceClient(EndpointConfiguration endpointConfiguration, string remoteAddress) : + base(TileServiceClient.GetBindingForEndpoint(endpointConfiguration), new System.ServiceModel.EndpointAddress(remoteAddress)) + { + this.Endpoint.Name = endpointConfiguration.ToString(); + ConfigureEndpoint(this.Endpoint, this.ClientCredentials); + } + + public TileServiceClient(EndpointConfiguration endpointConfiguration, System.ServiceModel.EndpointAddress remoteAddress) : + base(TileServiceClient.GetBindingForEndpoint(endpointConfiguration), remoteAddress) + { + this.Endpoint.Name = endpointConfiguration.ToString(); + ConfigureEndpoint(this.Endpoint, this.ClientCredentials); + } + + public TileServiceClient(System.ServiceModel.Channels.Binding binding, System.ServiceModel.EndpointAddress remoteAddress) : + base(binding, remoteAddress) + { + } + + public System.Threading.Tasks.Task DrawTilesAsync(int count) + { + return base.Channel.DrawTilesAsync(count); + } + + public virtual System.Threading.Tasks.Task OpenAsync() + { + return System.Threading.Tasks.Task.Factory.FromAsync(((System.ServiceModel.ICommunicationObject)(this)).BeginOpen(null, null), new System.Action(((System.ServiceModel.ICommunicationObject)(this)).EndOpen)); + } + + public virtual System.Threading.Tasks.Task CloseAsync() + { + return System.Threading.Tasks.Task.Factory.FromAsync(((System.ServiceModel.ICommunicationObject)(this)).BeginClose(null, null), new System.Action(((System.ServiceModel.ICommunicationObject)(this)).EndClose)); + } + + private static System.ServiceModel.Channels.Binding GetBindingForEndpoint(EndpointConfiguration endpointConfiguration) + { + if ((endpointConfiguration == EndpointConfiguration.BasicHttpBinding_ITileService)) + { + System.ServiceModel.BasicHttpBinding result = new System.ServiceModel.BasicHttpBinding(); + result.MaxBufferSize = int.MaxValue; + result.ReaderQuotas = System.Xml.XmlDictionaryReaderQuotas.Max; + result.MaxReceivedMessageSize = int.MaxValue; + result.AllowCookies = true; + result.Security.Mode = System.ServiceModel.BasicHttpSecurityMode.Transport; + return result; + } + throw new System.InvalidOperationException(string.Format("Could not find endpoint with name \'{0}\'.", endpointConfiguration)); + } + + private static System.ServiceModel.EndpointAddress GetEndpointAddress(EndpointConfiguration endpointConfiguration) + { + if ((endpointConfiguration == EndpointConfiguration.BasicHttpBinding_ITileService)) + { + return new System.ServiceModel.EndpointAddress("https://localhost:7121/TileService"); + } + throw new System.InvalidOperationException(string.Format("Could not find endpoint with name \'{0}\'.", endpointConfiguration)); + } + + private static System.ServiceModel.Channels.Binding GetDefaultBinding() + { + return TileServiceClient.GetBindingForEndpoint(EndpointConfiguration.BasicHttpBinding_ITileService); + } + + private static System.ServiceModel.EndpointAddress GetDefaultEndpointAddress() + { + return TileServiceClient.GetEndpointAddress(EndpointConfiguration.BasicHttpBinding_ITileService); + } + + public enum EndpointConfiguration + { + + BasicHttpBinding_ITileService, + } + } +} diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/HttpHeadersBehavior.cs b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/HttpHeadersBehavior.cs new file mode 100644 index 0000000..4168603 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/HttpHeadersBehavior.cs @@ -0,0 +1,108 @@ +using System.ServiceModel; +using System.ServiceModel.Channels; +using System.ServiceModel.Description; +using System.ServiceModel.Dispatcher; + +namespace CoreWCF.Helpers +{ + public class HttpHeadersBehavior : IEndpointBehavior + { + private Dictionary _headers = new Dictionary(); + + public IDictionary Headers => _headers; + + + // IEndpointBehavior Members + public void AddBindingParameters(ServiceEndpoint serviceEndpoint, System.ServiceModel.Channels.BindingParameterCollection bindingParameters) + { + return; + } + + public void ApplyClientBehavior(ServiceEndpoint serviceEndpoint, ClientRuntime behavior) + { + behavior.ClientMessageInspectors.Add(new HttpHeaderInjectionMessageInspector(_headers)); + } + + public void ApplyDispatchBehavior(ServiceEndpoint serviceEndpoint, EndpointDispatcher endpointDispatcher) + { + return; + //endpointDispatcher.DispatchRuntime.MessageInspectors.Add(new EndpointBehaviorMessageInspector()); + } + + public void Validate(ServiceEndpoint serviceEndpoint) + { + return; + } + + public class HttpHeaderInjectionMessageInspector : IClientMessageInspector + { + private IDictionary _headers; + + public HttpHeaderInjectionMessageInspector(IDictionary headers) + { + _headers = headers; + } + + public void AfterReceiveReply(ref Message reply, object correlationState) + { + } + + public object? BeforeSendRequest(ref Message request, IClientChannel channel) + { + HttpRequestMessageProperty? requestMessage; + if (request.Properties.ContainsKey(HttpRequestMessageProperty.Name)) + { + requestMessage = request.Properties[HttpRequestMessageProperty.Name] as HttpRequestMessageProperty; + } + else + { + requestMessage = new HttpRequestMessageProperty(); + request.Properties[HttpRequestMessageProperty.Name] = requestMessage; + } + if (requestMessage != null) + { + foreach (var prop in _headers) + { + requestMessage.Headers[prop.Key] = prop.Value; + } + } + return null; + } + } + } + + public static class HttpHeadersBehaviorHelpers + { + public static void AddRequestHeader(this ClientBase client, string HeaderName, string HeaderValue) where T : class + { + var behavior = GetHttpHeadersBehavior(client); + behavior.Headers[HeaderName] = HeaderValue; + } + + public static void AddRequestHeaders(this ClientBase client, IDictionary headers) where T : class + { + var behavior = GetHttpHeadersBehavior(client); + foreach (var header in headers) + { + behavior.Headers[header.Key] = header.Value; + } + } + + private static HttpHeadersBehavior GetHttpHeadersBehavior(ClientBase client) where T : class + { + HttpHeadersBehavior behavior; + + if (client.Endpoint.EndpointBehaviors.Contains(typeof(HttpHeadersBehavior))) + { + behavior = (HttpHeadersBehavior)client.Endpoint.EndpointBehaviors[typeof(HttpHeadersBehavior)]; + } + else + { + behavior = new HttpHeadersBehavior(); + client.Endpoint.EndpointBehaviors.Add(behavior); + } + + return behavior; + } + } +} diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Program.cs b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Program.cs new file mode 100644 index 0000000..1aa3d9f --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Program.cs @@ -0,0 +1,91 @@ +using Microsoft.Identity.Client; +using Microsoft.Identity.Web; +using ServiceReference1; +using System.ServiceModel.Channels; +using System.ServiceModel; +using CoreWCF.Helpers; + +await new Program().Run(args); + +partial class Program +{ + private Timer? _jwtRefreshTimer; + private IConfiguration? _configuration; + private TileServiceClient? _tileService; + + async Task Run(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + _configuration = builder.Configuration; + var app = builder.Build(); + + var tileServiceUrl = _configuration["TileServiceUrl"]; + _tileService = new TileServiceClient(TileServiceClient.EndpointConfiguration.BasicHttpBinding_ITileService, tileServiceUrl); + + await SetupJWTRefresh(); + + // Use Minimal WebApi Endpoint for the demo functionality + app.Map("/", () => Results.Redirect("/getTiles?Count=5")); + app.MapGet("/getTiles", (int count) => getTiles(count)); + + app.Run(); + } + + async Task> getTiles(int count) + { + return await _tileService.DrawTilesAsync(count); + } + + // Fetch the jwt, configure the header, and setup a timer to refresh the token + async Task SetupJWTRefresh() + { + // Fetch the jwt from AAD + var _jwt = await getAzAdJwt(_configuration); + + // Set the WCF Client to include the jwt as the `Authorization` header + _tileService.AddRequestHeader("Authorization", $"Bearer {_jwt.AccessToken}"); + + // JWT's have a timeout. Setup a timer 5 mins earlier to fetch a new token + // AAD service-to-service jwt don't have a refresh token, so fetch again based on client secret + var timeout = (_jwt.ExpiresOn - DateTimeOffset.Now).Subtract(TimeSpan.FromMinutes(5)); + _jwtRefreshTimer = new Timer((x) => _ = SetupJWTRefresh(), null, timeout, Timeout.InfiniteTimeSpan); + } + + + // Azure Active Directoy specific code to authenticate a daemon app with a service + // Based on https://github.com/Azure-Samples/active-directory-dotnetcore-daemon-v2/tree/master/4-Call-OwnApi-Pop + async Task getAzAdJwt(IConfiguration config) + { + // Fetch the auth details from configuration or the environment for secrets + var adTenant = config["AzureAdTenant"]; + var clientId = config["ClientId"]; + var clientSecret = config["ClientSecret"]; + var tileServiceScope = config["TileServiceScope"]; + + var authority = new Uri($"https://login.microsoftonline.com/{adTenant}"); + var scopes = new string[] { tileServiceScope }; + + var app = ConfidentialClientApplicationBuilder.Create(clientId) + .WithClientSecret(clientSecret) + .WithAuthority(authority) + .Build(); + app.AddInMemoryTokenCache(); + + AuthenticationResult result; + try + { + result = await app.AcquireTokenForClient(scopes) + .ExecuteAsync(); + } + catch (MsalServiceException ex) when (ex.Message.Contains("AADSTS70011")) + { + // Invalid scope. The scope has to be of the form "https://resourceurl/.default" + // Mitigation: change the scope to be as expected + + // Rethrow so we can see it in the application logs + throw ex; + } + + return result; + } +} diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Properties/launchSettings.json b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Properties/launchSettings.json new file mode 100644 index 0000000..cd242e4 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:38364", + "sslPort": 44366 + } + }, + "profiles": { + "WordGame": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7054;http://localhost:5122", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/WordGame.csproj b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/WordGame.csproj new file mode 100644 index 0000000..2d21bc3 --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/WordGame.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + 6101badd-1e58-43da-a6a9-427625862069 + + + + + + + + + + + + diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/appsettings.Development.json b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/appsettings.json b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/appsettings.json new file mode 100644 index 0000000..09fd05c --- /dev/null +++ b/Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/WordGame/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "TileServiceUrl": "https://localhost:49161/TileService", + + /* Azure AD properties for JWT Auth */ + "ClientId": "[Enter the Client Id of the service (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]", + "AzureAdTenant": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]", + "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]", + "TileServiceScope": "api://[Enter_client_ID_Of_TodoListService-v2_from_Azure_Portal,_e.g._2ec40e65-ba09-4853-bcde-bcb60029e596]/.default", + + /* DO NOT SPECIFY THIS HERE, See https://docs.microsoft.com/aspnet/core/security/app-secrets */ + "ClientSecret": "Specify this property in a user secrets file instead" + +}