Securing .NET APIs with Amazon Cognito
In this article, we delve into securing .NET APIs with Amazon Cognito, specifically focusing on ensuring authenticated users have the necessary permissions to access resources. Building on the foundation laid in the previous article, Amazon Cognito Authentication with Hosted UI for ASP.NET Core Apps, we encourage you to first download the code provided there.
Resource Server
The resource server is the OAuth 2.0 term for your API server. The resource server handles authenticated requests after the application has obtained an access token.
The resource server will be getting requests from applications with an HTTP
Authorization
header containing an access token. The resource server needs to be able to verify the access token to determine whether to process the request, and find the associated user account, etc.
Amazon Cognito allows us to define Resource Servers and associate scopes with them. These scopes can be used to authorize specific actions in our APIs. Our user pool client requests the scopes as part of the authentication process and includes them inside the access token used against the resource server, which can then make decisions based on those scopes. Let's start updating the template.yaml
file as follows:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
SAM Template
Resources:
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UsernameAttributes:
- email
UsernameConfiguration:
CaseSensitive: false
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: false
RequireNumbers: false
RequireSymbols: false
RequireUppercase: false
TemporaryPasswordValidityDays: 7
MfaConfiguration: 'OFF'
AccountRecoverySetting:
RecoveryMechanisms:
- Name: verified_email
Priority: 1
AdminCreateUserConfig:
AllowAdminCreateUserOnly: false
AutoVerifiedAttributes:
- email
UserPoolName: "myuserpool"
UserAttributeUpdateSettings:
AttributesRequireVerificationBeforeUpdate:
- email
Schema:
- Name: email
AttributeDataType: String
Mutable: false
Required: true
EmailConfiguration:
EmailSendingAccount: COGNITO_DEFAULT
UserPoolClient:
Type: "AWS::Cognito::UserPoolClient"
DependsOn: UserPoolResourceServer
Properties:
ClientName: "myclient"
GenerateSecret: true
UserPoolId: !Ref UserPool
ExplicitAuthFlows:
- ALLOW_USER_SRP_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
SupportedIdentityProviders:
- COGNITO
AllowedOAuthFlowsUserPoolClient: true
AllowedOAuthFlows:
- code
AllowedOAuthScopes:
- email
- openid
- profile
- aws.cognito.signin.user.admin
- weatherapi/read
LogoutURLs:
- "https://localhost:7119/Account/Loggedout"
CallbackURLs:
- "https://localhost:7119/signin-oidc"
UserDomainPool:
Type: AWS::Cognito::UserPoolDomain
Properties:
Domain: "myuserdomainpoolx95"
UserPoolId: !Ref UserPool
UserPoolResourceServer:
Type: AWS::Cognito::UserPoolResourceServer
Properties:
Identifier: weatherapi
Name: Weather API
Scopes:
- ScopeName: read
ScopeDescription: "Read access"
UserPoolId: !Ref UserPool
Outputs:
CognitoUserPoolID:
Value: !Ref UserPool
Description: The UserPool ID
CognitoClientID:
Value: !Ref UserPoolClient
Description: The app client
HostedUIDomain:
Value: !Ref UserDomainPool
Description: Hosted UI domain
From the original file, we added the AWS::Cognito::UserPoolResourceServer
resource:
Name
: A unique identifier for the resource server within the user pool.Identifier
: A unique identifier for the resource server within the user pool.Scopes
: An array that defines the permissions associated with the resource server. Each scope has a name and a description.
The final step is to add the new scope to the AllowedOAuthScopes
property of the AWS::Cognito::UserPoolClient
resource. The format for the scope is always ResourceServerIdentifier/ScopeName
. Run the following commands to update the resources:
sam build
sam deploy --guided
The target API
Let's set up the API that our application will invoke. Run the following commands:
dotnet new webapi -n MyWebApi
dotnet sln add --in-root MyWebApi
dotnet add MyWebApi package Microsoft.AspNetCore.Authentication.JwtBearer
Open the MyWebApi
project and update the Program.cs
file as follows:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
var configuration = builder.Configuration;
options.MetadataAddress = $"https://cognito-idp.{configuration["AWS:Region"]}.amazonaws.com/{configuration["AWS:UserPoolId"]}/.well-known/openid-configuration";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateLifetime = true,
ValidateAudience = true,
ValidAudience = configuration["AWS:UserPoolClientId"],
AudienceValidator = (audiences, securityToken, validationParameters) =>
{
var token = securityToken as JsonWebToken;
var clientId = token?.GetClaim("client_id").Value;
return validationParameters.ValidateAudience ? validationParameters.ValidAudience.Equals(clientId) : true;
}
};
}
);
builder.Services.AddAuthorization();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", [Authorize] () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast")
.WithOpenApi();
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
The AddJwtBearer
method sets up the JSON Web Token (JWT) validation middleware, which enables handle access tokens:
MetadataAddress
: Specifies the well-known OpenID Connect Discovery endpoint provided by the Amazon Cognito user pool. The authentication middleware will retrieve the metadata from this endpoint to configure itself, including validating the token issuer, signing keys, and other parameters.TokenValidationParameters
: Provides a range of properties used to configure token validation when working with JWTs:ValidateIssuerSigningKey
: Indicates whether the token's issuer signing key should be validated. The security key is typically used to verify that the JWT token has not been tampered with and was issued by a trusted authority. When set totrue
, the token validation process checks whether the issuer signing key provided by the token matches the key(s) specified in theIssuerSigningKey
orIssuerSigningKeys
properties.ValidateIssuer
: Indicates whether the token's issuer (iss
claim) should be validated. The issuer represents the entity that issued the token, typically an identity provider or authentication service. When set totrue
, the token validation process checks whether the issuer of the JWT token matches the value specified in theValidIssuer
property.ValidateLifetime
: Indicates whether the token's lifetime should be validated. The lifetime of a JWT token is determined by theexp
(expiration time) andnbf
(not before) claims included in the token. When set totrue
, the token validation process checks whether the current time falls within the validity period defined by the expiration time and not before claims.ValidateAudience
: Indicates whether the token's audience should be validated. The audience represents the intended recipient or audience for which the JWT token was issued. When set totrue
, the token validation process checks whether the audience (aud
claim) of the JWT token matches the expected audience specified in theValidAudience
property. According to the OAuth 2.0 specification, theaud
claim doesn't have to be present in JWT tokens. That's why Amazon Cognito does not include the claim; we can skip the validation entirely or perform a custom one by using the audience from theclient_id
claim.ValidAudience
: The audience that tokens must have to be considered valid. The audience represents the intended recipient of the token, typically a client application or resource server authorized to accept and process the token. The propertyValidAudiences
accepts an array of audience values.AudienceValidator
: A delegate for validating the audience of a token. The delegate returns a boolean value indicating whether the audience claims in the token are considered valid according to custom validation logic.
Don't forget to add the AddAuthorization
method and the corresponding UseAuthentication
and UseAuthorization
middleware. The last step is adding the [Authorize]
attribute to secure our endpoint. Open the appsettings.json
file to update its content as follows:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AWS": {
"Region": "<MY_REGION>",
"UserPoolClientId": "<MY_CLIENT_ID>",
"UserPoolId": "<MY_USER_POOL_ID>"
}
}
The application client
Let's update the application client to invoke the target API. We will use the Duende.AccessTokenManagement library to automatically manage the access token's lifecycle using a refresh token. Run the following command:
dotnet add MyWebApp package Duende.AccessTokenManagement.OpenIdConnect
dotnet add MyWebApp Microsoft.IdentityModel.Protocols.OpenIdConnect
Create the Models/WeatherViewModel.cs
file with the following content:
public class WeatherViewModel
{
public int TemperatureC { get; set; }
public int TemperatureF { get; set; }
public string? Summary { get; set; }
public DateOnly @model WeatherViewModel[]
@{
ViewData["Title"] = "Weather";
}
<h1>@ViewData["Title"]</h1>
<table class="table table-bordered">
<tr>
<th>Date</th>
<th>Summary</th>
<th>TemperatureC</th>
<th>TemperatureF</th>
</tr>
@foreach (var item in Model)
{
<tr>
<td>
@item.Date
</td>
<td>
@item.Summary
</td>
<td>
@item.TemperatureC
</td>
<td>
@item.TemperatureF
</td>
</tr>
}
</table>{ get; set; }
}
Open the Controllers/HomeController.cs
and update the content as follows:
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
private readonly IHttpClientFactory _httpClientFactory;
public HomeController(ILogger<HomeController> logger,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
public IActionResult Index()
{
return View();
}
public IActionResult Privacy()
{
return View();
}
public async Task<IActionResult> GetWeather()
{
var client = _httpClientFactory.CreateClient("api");
var response = await client.GetAsync("weatherforecast");
response.EnsureSuccessStatusCode();
var models = await response.Content.ReadFromJsonAsync<WeatherViewModel[]>();
return View(models);
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}
We inject the IHttpClientFactory
dependency into the controller and create a new GetWeather
method to call the target API. As we can see, there is no special code related to the tokens because it's handled behind the scenes by the library. Create the View/GetWeather.cshtml
file with the following content:
@model WeatherViewModel[]
@{
ViewData["Title"] = "Weather";
}
<h1>@ViewData["Title"]</h1>
<table class="table table-bordered">
<tr>
<th>Date</th>
<th>Summary</th>
<th>TemperatureC</th>
<th>TemperatureF</th>
</tr>
@foreach (var item in Model)
{
<tr>
<td>
@item.Date
</td>
<td>
@item.Summary
</td>
<td>
@item.TemperatureC
</td>
<td>
@item.TemperatureF
</td>
</tr>
}
</table>
Open the Shared/_Layout.cshtml
file and update the content as follows:
@using Microsoft.AspNetCore.Identity
@using Amazon.Extensions.CognitoAuthentication
@using System.Security.Claims
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - MyWebApp</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/MyWebApp.styles.css" asp-append-version="true" />
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container-fluid">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">MyWebApp</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
</ul>
<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="GetWeather">Weather</a>
</li>
<li class="nav-item">
<span class="nav-link text-dark">Hello @User.FindFirstValue(ClaimTypes.Email)!</span>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Account" asp-action="Logout">Logout</a>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link text-dark" id="login" asp-area="" asp-controller="Account" asp-action="Login" asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })">Login</a>
</li>
}
</ul>
</div>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
© 2024 - MyWebApp - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
Here, we're adding a link to call the API once the user is authenticated. Open the Program.cs
file and update the content as follows:
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
builder.Services.AddControllersWithViews();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.ResponseType = OpenIdConnectResponseType.Code;
options.MetadataAddress = $"https://cognito-idp.{configuration["AWS:Region"]}.amazonaws.com/{configuration["AWS:UserPoolId"]}/.well-known/openid-configuration";
options.ClientId = configuration["AWS:UserPoolClientId"];
options.ClientSecret = configuration["AWS:UserPoolClientSecret"];
options.UsePkce = true;
options.Events = new OpenIdConnectEvents()
{
OnRedirectToIdentityProviderForSignOut = OnRedirectToIdentityProviderForSignOut
};
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("email");
options.Scope.Add("aws.cognito.signin.user.admin");
options.Scope.Add("profile");
options.Scope.Add("weatherapi/read");
options.SaveTokens = true;
Task OnRedirectToIdentityProviderForSignOut(RedirectContext context)
{
context.ProtocolMessage.Scope = "openid";
context.ProtocolMessage.ResponseType = OpenIdConnectResponseType.Code;
var cognitoDomain = $"https://{configuration["AWS:Domain"]}.auth.{configuration["AWS:Region"]}.amazoncognito.com" ;
var clientId = configuration["AWS:UserPoolClientId"]; ;
var logoutUrl = $"{context.Request.Scheme}://{context.Request.Host}{configuration["AWS:AppSignOutUrl"]}";
context.ProtocolMessage.IssuerAddress = $"{cognitoDomain}/logout?client_id={clientId}&logout_uri={logoutUrl}&redirect_uri={logoutUrl}";
context.Properties.Items.Remove(CookieAuthenticationDefaults.AuthenticationScheme);
context.Properties.Items.Remove(OpenIdConnectDefaults.AuthenticationScheme);
return Task.CompletedTask;
}
});
builder.Services.AddOpenIdConnectAccessTokenManagement();
builder.Services.AddHttpClient("api", client =>
{
client.BaseAddress = new Uri("https://localhost:7064/");
})
.AddUserAccessTokenHandler();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
The changes here are:
In the
AddOpenIdConnect
method, we added the new scopeweatherapi/read
to the list.We added the
AddOpenIdConnectAccessTokenManagement
method to utilize the Duende library.A delegate handler is added to the HTTP client using the
AddUserAccessTokenHandler
method to include the access token every time it is used.
And that's it. Run the application and enjoy our secure API.
All the code can be found here. Thanks, and happy coding.
Subscribe to my newsletter
Read articles from Raul Naupari directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Raul Naupari
Raul Naupari
Somebody who likes to code