Created
February 27, 2021 01:16
-
-
Save joelverhagen/e41253afa4d532445e5d8c35ca0cefec to your computer and use it in GitHub Desktop.
Manual validate AAD OAuth 2.0 JWT in Azure Functions
This file contains 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
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; | |
} | |
} | |
} |
This file contains 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
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); | |
} | |
} | |
} |
This file contains 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
<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> |
This file contains 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
{ | |
"version": "2.0", | |
"logging": { | |
"applicationInsights": { | |
"samplingExcludedTypes": "Request", | |
"samplingSettings": { | |
"isEnabled": true | |
} | |
} | |
} | |
} |
This file contains 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
{ | |
"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