Skip to content

Instantly share code, notes, and snippets.

@joelverhagen
Created February 27, 2021 01:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save joelverhagen/e41253afa4d532445e5d8c35ca0cefec to your computer and use it in GitHub Desktop.
Save joelverhagen/e41253afa4d532445e5d8c35ca0cefec to your computer and use it in GitHub Desktop.
Manual validate AAD OAuth 2.0 JWT in Azure Functions
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Security.Claims;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Collections.Concurrent;
namespace FunctionApp1
{
public static class AuthHelper
{
private static readonly ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> UrlToConfigurationManager
= new ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>>();
public static async Task<ClaimsPrincipal> AuthenticateAsync(HttpRequest req, string tenantId, string audience, string role, ILogger logger)
{
var accessToken = GetAccessToken(req, logger);
if (accessToken == null)
{
return null;
}
var claimsPrincipal = await ValidateAccessToken(accessToken, tenantId, audience, logger);
if (claimsPrincipal == null)
{
return null;
}
if (!HasValidClaims(claimsPrincipal, role, logger))
{
return null;
}
return claimsPrincipal;
}
private static string GetAccessToken(HttpRequest req, ILogger logger)
{
var authorizationHeader = req.Headers?["Authorization"];
var parts = authorizationHeader?.ToString().Split(' ');
if (parts == null || parts.Length != 2 || parts[0] != "Bearer")
{
logger.LogInformation("Request did not contain a valid 'Authorization: Bearer ...' header.");
return null;
}
return parts[1];
}
private static async Task<ClaimsPrincipal> ValidateAccessToken(string accessToken, string tenantId, string audience, ILogger logger)
{
var configurationManager = UrlToConfigurationManager.GetOrAdd(
$"https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration",
x => new ConfigurationManager<OpenIdConnectConfiguration>(x, new OpenIdConnectConfigurationRetriever()));
var config = await configurationManager.GetConfigurationAsync();
var validationParameters = new TokenValidationParameters
{
ValidIssuer = $"https://sts.windows.net/{tenantId}/",
ValidAudiences = new[] { audience },
IssuerSigningKeys = config.SigningKeys,
};
var tokenValidator = new JwtSecurityTokenHandler();
try
{
return tokenValidator.ValidateToken(accessToken, validationParameters, out var securityToken);
}
catch (SecurityTokenException ex)
{
logger.LogInformation(ex, "JWT authentication failed.");
return null;
}
}
private static bool HasValidClaims(ClaimsPrincipal claimsPrincipal, string role, ILogger logger)
{
// Check this because I see reference to a spooky "public client" value for "0".
var credentialTypes = claimsPrincipal.FindAll("appidacr");
if (!credentialTypes.All(x => x.Value == "1" || x.Value == "2")) // token or certificate
{
logger.LogInformation("Claims principal had an unexpected 'appidacr' value.");
return false;
}
var appIds = claimsPrincipal.FindAll("appid");
if (appIds.Count() != 1)
{
logger.LogInformation("Claims principal did not have exactly one 'appid'.");
return false;
}
var roles = claimsPrincipal.FindAll(ClaimTypes.Role);
if (!roles.Any(x => x.Value == role))
{
logger.LogInformation("Claims principal did not contain the expected role.");
return false;
}
return true;
}
}
}
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System.Linq;
namespace FunctionApp1
{
public static class Function1
{
private const string TenantId = "<tenant ID>";
private const string Audience = "<app ID audience>";
private const string Role = "<app role name>";
[FunctionName("Function1")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req,
ILogger logger)
{
logger.LogInformation("C# HTTP trigger function processed a request.");
var claimsPrincipal = await AuthHelper.AuthenticateAsync(req, TenantId, Audience, Role, logger);
if (claimsPrincipal == null)
{
return new UnauthorizedResult();
}
string name = claimsPrincipal.FindAll("appid").Single().Value;
string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
dynamic data = JsonConvert.DeserializeObject(requestBody);
name = name ?? data?.name;
string responseMessage = string.IsNullOrEmpty(name)
? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
: $"Hello, {name}. This HTTP triggered function executed successfully.";
return new OkObjectResult(responseMessage);
}
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<AzureFunctionsVersion>v3</AzureFunctionsVersion>
<_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="6.8.0" />
<PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.11" />
</ItemGroup>
<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingExcludedTypes": "Request",
"samplingSettings": {
"isEnabled": true
}
}
}
}
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment