Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
PaoloPia committed Jan 15, 2024
2 parents 1c759a4 + 0335735 commit f3a2aea
Show file tree
Hide file tree
Showing 44 changed files with 968 additions and 57 deletions.
Binary file added samples/ace-expense-report/Restaurant-Receipt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace PnP.Ace.ExpenseReport.Backend
{
public class ExpenseReport
{
public string ReceiptFileName { get; set; }
public string ReceiptContent { get; set; }
public string Description { get; set; }
public string Category { get; set; }
public string Date { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace PnP.Ace.ExpenseReport.Backend
{
internal static class ClaimTypes
{
internal const string ScopeClaimType = "http://schemas.microsoft.com/identity/claims/scope";
internal const string RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
internal const string AppIdClaimType = "appid";
internal const string TenantIdClaimType = "http://schemas.microsoft.com/identity/claims/tenantid";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.Extensions.Configuration;
using System.Text.RegularExpressions;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;

namespace PnP.Ace.ExpenseReport.Backend
{
public class FunctionAuthenticationMiddleware : IFunctionsWorkerMiddleware
{
private readonly JwtSecurityTokenHandler _tokenValidator;
private readonly TokenValidationParameters _tokenValidationParameters;
private readonly ConfigurationManager<OpenIdConnectConfiguration> _configurationManager;

private static Regex _tenantIdRegEx = new Regex(@"(?<STSTrailer>(https:\/\/sts\.windows\.net\/))(?<TenantId>((\w|\-)*))\/");

public FunctionAuthenticationMiddleware(IConfiguration configuration)
{
var tenantId = configuration["TenantId"];
var audience = configuration["Audience"];
var authority = configuration["Authority"];

_tokenValidator = new JwtSecurityTokenHandler();
_tokenValidationParameters = new TokenValidationParameters
{
ValidAudience = audience,
IssuerValidator = (string issuer, SecurityToken securityToken, TokenValidationParameters validationParameters) => {

// An option is to use Dynamic Issuer validation, searching
// the issuer via an external repository and validating it accordingly

// Check if we have proper value for issuer claim
if (_tenantIdRegEx.IsMatch(issuer))
{
// Try to extract the TenantId
var tenantId = _tenantIdRegEx.Matches(issuer).FirstOrDefault()?.Groups["TenantId"].Value;

// If we have the TenantId
if (!string.IsNullOrEmpty(tenantId))
{
// Convert the ID into a GUID
var tenantIdValue = new Guid(tenantId);

// This is a demo API so we do nothing
// However, in a real solution you should search the tenant in a backend repository

// Always consider the tenant as a valid one
return issuer;
}
}

// Otherwise, the issuer is not valid!
throw new SecurityTokenInvalidIssuerException(
$"IDW10303: Issuer: '{issuer}', does not match any of the valid issuers provided for this application.");
}
};

_configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever());
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
// Get the invoked function method
var targetMethod = context.GetTargetFunctionMethod();

// Get the FunctionAuthorize attribute, if any
var allowAnonymousAttribute = targetMethod.GetCustomAttribute<AllowAnonymousAttribute>();

if (allowAnonymousAttribute != null ||
targetMethod.DeclaringType?.FullName == "Microsoft.Azure.Functions.Worker.Extensions.OpenApi.DefaultOpenApiHttpTrigger") {
// Skip the authentication code because we allow anonymous
await next(context);
return;
}

// Try to get the access token from the request headers, if any
if (!TryGetTokenFromHeaders(context, out var token))
{
// Unable to get token from headers
context.SetHttpResponseStatusCode(HttpStatusCode.Unauthorized);
return;
}

if (!_tokenValidator.CanReadToken(token))
{
// Token is malformed
context.SetHttpResponseStatusCode(HttpStatusCode.Unauthorized);
return;
}

// Get OpenID Connect metadata
var validationParameters = _tokenValidationParameters.Clone();
var openIdConfig = await _configurationManager.GetConfigurationAsync(default);

// validationParameters.ValidateIssuer = false;
// validationParameters.ValidIssuers = new string[] {
// "https://sts.windows.net/26a540dd-4476-4541-b1ec-cfdd29e25b14/",
// "https://sts.windows.net/6c94075a-da0a-4c6a-8411-badf652e8b53/"
// };
// validationParameters.ValidIssuer = openIdConfig.Issuer;

validationParameters.IssuerSigningKeys = openIdConfig.SigningKeys;

try
{
// Validate token
var principal = _tokenValidator.ValidateToken(
token, validationParameters, out _);

// Set principal + token in Features collection
// They can be accessed from here later in the call chain
context.Features.Set(new JwtPrincipalFeature(principal, token));

await next(context);
}
catch (SecurityTokenException)
{
// Token is not valid (expired etc.)
context.SetHttpResponseStatusCode(HttpStatusCode.Unauthorized);
return;
}
}

private static bool TryGetTokenFromHeaders(FunctionContext context, out string token)
{
token = string.Empty;

// HTTP headers are in the binding context as a JSON object
// The first checks ensure that we have the JSON string
if (!context.BindingContext.BindingData.TryGetValue("Headers", out var headersObj))
{
return false;
}

if (headersObj is not string headersStr)
{
return false;
}

// Deserialize headers from JSON
var headers = JsonSerializer.Deserialize<Dictionary<string, string>>(headersStr);
if (headers != null) {

var normalizedKeyHeaders = headers.ToDictionary(h => h.Key.ToLowerInvariant(), h => h.Value);
if (!normalizedKeyHeaders.TryGetValue("authorization", out var authHeaderValue))
{
// No Authorization header present
return false;
}

if (!authHeaderValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
// Scheme is not Bearer
return false;
}

token = authHeaderValue.Substring("Bearer ".Length).Trim();
return true;
}

return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.Extensions.Configuration;
using System.Security.Claims;
using System.Reflection;

namespace PnP.Ace.ExpenseReport.Backend
{
public class FunctionAuthorizationMiddleware : IFunctionsWorkerMiddleware
{
public async Task Invoke(
FunctionContext context,
FunctionExecutionDelegate next)
{
// Get the consumer's princiapl from the function context
var principalFeature = context.Features.Get<JwtPrincipalFeature>();

if (principalFeature != null)
{
// Get the invoked function method
var targetMethod = context.GetTargetFunctionMethod();

// Get the FunctionAuthorize attribute, if any
var functionAuthorizeAttribute = targetMethod.GetCustomAttribute<FunctionAuthorizeAttribute>();

// In case there is the FunctionAuthorize attribute
// let's check authorization based on the accepted scopes
if (functionAuthorizeAttribute != null && !AuthorizePrincipal(context,
principalFeature.Principal, functionAuthorizeAttribute.Scopes,
functionAuthorizeAttribute.Roles))
{
context.SetHttpResponseStatusCode(HttpStatusCode.Forbidden);
return;
}

// If the FunctionAuthorize attribute requires me to run on-behalf-of the user
if (functionAuthorizeAttribute != null && functionAuthorizeAttribute.RunOnBehalfOf)
{
// let's get the OBO token
var tenantId = principalFeature.Principal.FindFirst(c => c.Type == ClaimTypes.TenantIdClaimType);
if (tenantId != null)
{
var oboToken = await SecurityHelper.GetOboToken(principalFeature.AccessToken, tenantId.Value);
// and update the principal feature
var updatedPrincipalFeature = new JwtPrincipalFeature(principalFeature.Principal, principalFeature.AccessToken, oboToken);
context.Features.Set<JwtPrincipalFeature>(updatedPrincipalFeature);
}
}
}

await next(context);
}

private static bool AuthorizePrincipal(FunctionContext context, ClaimsPrincipal principal, string[] acceptedScopes, string[] acceptedRoles)
{
// This authorization implementation was made
// for Azure AD. Your identity provider might differ.

if (principal.HasClaim(c => c.Type == ClaimTypes.ScopeClaimType))
{
// Request made with delegated permissions, check scopes and user roles
return AuthorizeDelegatedPermissions(context, principal, acceptedScopes);
}
else if (principal.HasClaim(c => c.Type == ClaimTypes.RoleClaimType))
{
// Request made with delegated permissions, check scopes and user roles
return AuthorizeApplicationPermissions(context, principal, acceptedRoles);
}
else
{
// If we don't have the scope claim, we cannot authorize the request
return false;
}
}

private static bool AuthorizeDelegatedPermissions(FunctionContext context, ClaimsPrincipal principal, string[] acceptedScopes)
{
// Scopes are stored in a single claim, space-separated
var callerScopes = (principal.FindFirst(ClaimTypes.ScopeClaimType)?.Value ?? "")
.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var callerHasAcceptedScopes = callerScopes.Any(cs => acceptedScopes.Contains(cs));

return callerHasAcceptedScopes;
}

private static bool AuthorizeApplicationPermissions(FunctionContext context, ClaimsPrincipal principal, string[] acceptedRoles)
{
// Scopes are stored in a single claim, space-separated
var callerRoles = (principal.FindFirst(ClaimTypes.RoleClaimType)?.Value ?? "")
.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var callerHasAcceptedRoles = callerRoles.Any(cs => acceptedRoles.Contains(cs));

return callerHasAcceptedRoles;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs.Host;
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using Microsoft.Identity.Web;

namespace PnP.Ace.ExpenseReport.Backend
{
/// <summary>
/// Custom attribute to provide custom authorization logic for Funtion App functions
/// </summary>
/// <remarks>
/// This attribute can only be applied to methods
/// </remarks>
[AttributeUsage(AttributeTargets.Method)]
internal class FunctionAuthorizeAttribute : Attribute
{
/// <summary>
/// Defines which scopes (aka delegated permissions) are accepted
/// </summary>
public string[] Scopes { get; set; } = Array.Empty<string>();

/// <summary>
/// Defines which roles (aka application permissions) are accepted
/// </summary>
public string[] Roles { get; set; } = Array.Empty<string>();

/// <summary>
/// Defines whether to run the request on-behalf-of the current user
/// </summary>
public bool RunOnBehalfOf { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Net;
using System.Reflection;
using Microsoft.Azure.Functions.Worker;

namespace PnP.Ace.ExpenseReport.Backend
{
internal static class FunctionContextExtensions
{
internal static void SetHttpResponseStatusCode(this FunctionContext context, HttpStatusCode statusCode)
{
var coreAssembly = Assembly.Load("Microsoft.Azure.Functions.Worker.Core");
var featureInterfaceName = "Microsoft.Azure.Functions.Worker.Context.Features.IFunctionBindingsFeature";
var featureInterfaceType = coreAssembly.GetType(featureInterfaceName);
var bindingsFeature = context.Features.Single(
f => f.Key.FullName == featureInterfaceType?.FullName).Value;
var invocationResultProp = featureInterfaceType?.GetProperty("InvocationResult");

var grpcAssembly = Assembly.Load("Microsoft.Azure.Functions.Worker.Grpc");
var responseDataType = grpcAssembly.GetType("Microsoft.Azure.Functions.Worker.GrpcHttpResponseData");
if (responseDataType != null)
{
var responseData = Activator.CreateInstance(responseDataType, context, statusCode);
if (responseData != null)
{
invocationResultProp?.SetMethod?.Invoke(bindingsFeature, new object[] { responseData });
}
}
}

internal static MethodInfo GetTargetFunctionMethod(this FunctionContext context)
{
// This contains the fully qualified name of the method
// E.g. IsolatedFunctionAuth.TestFunctions.ScopesAndAppRoles
var entryPoint = context.FunctionDefinition.EntryPoint;

var assemblyPath = context.FunctionDefinition.PathToAssembly;
var assembly = Assembly.LoadFrom(assemblyPath);
var typeName = entryPoint.Substring(0, entryPoint.LastIndexOf('.'));
var type = assembly.GetType(typeName);
var methodName = entryPoint.Substring(entryPoint.LastIndexOf('.') + 1);
var method = type?.GetMethod(methodName);

if (method == null)
{
throw new Exception($"Could not find method {entryPoint} in assembly {assemblyPath}");
}

return method;
}
}
}
Loading

0 comments on commit f3a2aea

Please sign in to comment.