-
Notifications
You must be signed in to change notification settings - Fork 21
Service to Service Auth using JWT #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
samsp-msft
wants to merge
5
commits into
main
Choose a base branch
from
samsp/auth1
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+891
−0
Draft
Changes from 2 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
107 changes: 107 additions & 0 deletions
107
Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/Readme.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. In this specific sample, it is using Azure Active Directory as the identity provider, and the calling app is another service. The same pattern will apply to other JWT based authentication providers or to end-user rather than service to service authentication. | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
## What is a JWT | ||
|
||
[Jason Web Token](https://en.wikipedia.org/wiki/JSON_Web_Token) 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, then they are handed back a signed token, which includes information about the resource and rights. They then pass that token to the service, which can verify its integrity, and then trust the claims it specifies. | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
JWT is an open standard, so is supported by multiple server stacks, clients, code languages and identity providers. | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
## 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 can be easily replaced with a different identity provider: | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
- In the client the code & settings to authenticate with AAD and retrieve the token | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
- In the server, the code & settings to use AAD as the JWT provider for ASP.NET Core | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
If you wish to run the samples as-is then you will need to register the applications with an existing Azure AD Tenent, or create a new one. This sample is based off 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 it's readme. | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
## 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, and at runtime come from some form of secure storage, and definetely should not included in source control for the world to see! .NET makes this easier to manage through the configuration API and built-in overlays: | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
- 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. | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
- At runtime, Environment Variables will overlay over 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. Eg [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) | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
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 authenticate header. So these samples add that header and validate it as part of the service call. | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
## Azure AD configuration | ||
Within the Azure AD Tenent used for the 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. | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
- 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 | ||
- Granted the API Permission of `AccessTileBag` for the Tile Service API | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
## Tile Service (Server app) | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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: | ||
- Nuget references for `Microsoft.Identity.Web` and `Microsoft.VisualStudio.Azure.Containers.Tools.Targets` | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
- Including the ASP.NET Core and Azure AD SDK support for JWT with | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
``` c# | ||
// Asp.NET Core Authentication | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: include links to docs for things like AuthorizeRole |
||
|
||
``` c# | ||
[AuthorizeRole("AccessTileBag")] | ||
public IList<GameTile> DrawTiles(int count) | ||
{ | ||
... | ||
} | ||
``` | ||
- The parameters for the Azure SDK to validate the JWT are provided through configuration in appsettings.json | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
``` 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 (appsetting.json) and User Secrets | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
- 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 `Authorize` header with the JWT as the value | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
- 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 performing user authentication etc. | ||
samsp-msft marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
31 changes: 31 additions & 0 deletions
31
Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/ServiceToServiceJWTSample.sln
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
25 changes: 25 additions & 0 deletions
25
Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/.dockerignore
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
22 changes: 22 additions & 0 deletions
22
Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Dockerfile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"] |
123 changes: 123 additions & 0 deletions
123
Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/ITileService.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
using CoreWCF; | ||
using System; | ||
using System.Runtime.Serialization; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.AspNetCore.Authorization; | ||
using Microsoft.Identity.Web.Resource; | ||
|
||
namespace TileService | ||
{ | ||
[ServiceContract] | ||
public interface ITileService | ||
{ | ||
[OperationContract] | ||
IList<GameTile> DrawTiles(int count); | ||
} | ||
|
||
public class TileBag : ITileService | ||
{ | ||
private static IList<GameTile> _gameTiles = initTileCollection(); | ||
private static int TileCount; | ||
private static Random rnd = new Random(); | ||
|
||
// [AuthorizeRole("AccessTileBag")] | ||
public IList<GameTile> DrawTiles(int count) | ||
{ | ||
|
||
// TODO: Remove this block | ||
var ctxa = new HttpContextAccessor(); | ||
var ctx = ctxa.HttpContext; | ||
if (ctx == null) { Console.WriteLine("no http context"); } | ||
else | ||
{ | ||
foreach (var claim in ctx.User.Claims) | ||
{ | ||
Console.WriteLine("***** BEGIN CLAIM *****"); | ||
Console.WriteLine(claim.ToString()); | ||
Console.WriteLine("***** END CLAIM *****"); | ||
} | ||
Console.WriteLine("***** BEGIN HEADERS *****"); | ||
foreach (var h in ctx.Request.Headers) | ||
{ | ||
Console.WriteLine(h.Key + ":" + h.Value); | ||
} | ||
Console.WriteLine("***** END HEADERS *****"); | ||
} | ||
if (count > 0) | ||
{ | ||
var tiles = new List<GameTile>(); | ||
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 static IList<GameTile> initTileCollection() | ||
{ | ||
var tiles = new List<GameTile>(); | ||
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; | ||
} | ||
} | ||
} |
39 changes: 39 additions & 0 deletions
39
Scenarios/Authentication/Service-to-service-JWT-using-Azure-AD/TileService/Program.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
using Microsoft.Identity.Web; | ||
using System.IdentityModel.Tokens.Jwt; | ||
using Microsoft.AspNetCore.Authentication.JwtBearer; | ||
|
||
var builder = WebApplication.CreateBuilder(); | ||
|
||
// CoreWCF Services | ||
builder.Services.AddServiceModelServices(); | ||
builder.Services.AddServiceModelMetadata(); | ||
builder.Services.AddSingleton<IServiceBehavior, UseRequestHeadersForMetadataAddressBehavior>(); | ||
|
||
// Asp.NET Core Authentication | ||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) | ||
.AddMicrosoftIdentityWebApi(builder.Configuration); | ||
|
||
//TODO: Remove | ||
builder.Services.AddHttpContextAccessor(); | ||
|
||
var app = builder.Build(); | ||
|
||
if (app.Environment.IsDevelopment()) | ||
{ | ||
Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; | ||
samsp-msft marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
app.UseAuthentication(); | ||
app.UseAuthorization(); | ||
|
||
app.UseServiceModel(serviceBuilder => | ||
{ | ||
serviceBuilder.AddService<TileBag>(); | ||
serviceBuilder.AddServiceEndpoint<TileBag, ITileService>(new BasicHttpBinding(BasicHttpSecurityMode.Transport), "https://host/TileService"); | ||
var serviceMetadataBehavior = app.Services.GetRequiredService<ServiceMetadataBehavior>(); | ||
serviceMetadataBehavior.HttpsGetEnabled = true; | ||
}); | ||
|
||
app.MapGet("/", () => "Error incorrect path: Use /TileService to access the service"); | ||
|
||
app.Run(); |
17 changes: 17 additions & 0 deletions
17
...tication/Service-to-service-JWT-using-Azure-AD/TileService/Properties/launchSettings.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.