-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' of https://github.com/pnp/spfx-reference-scenarios
- Loading branch information
Showing
44 changed files
with
968 additions
and
57 deletions.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions
17
samples/ace-expense-report/ace-expense-report-backend/ExpenseReport.cs
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
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; } | ||
} | ||
} |
10 changes: 10 additions & 0 deletions
10
samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/ClaimTypes.cs
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
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"; | ||
} | ||
} |
172 changes: 172 additions & 0 deletions
172
...report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthenticationMiddleware.cs
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
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; | ||
} | ||
} | ||
} |
102 changes: 102 additions & 0 deletions
102
...-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthorizationMiddleware.cs
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
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; | ||
} | ||
} | ||
} |
37 changes: 37 additions & 0 deletions
37
...pense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthorizeAttribute.cs
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
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; } | ||
} | ||
} |
51 changes: 51 additions & 0 deletions
51
...xpense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionContextExtensions.cs
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
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; | ||
} | ||
} | ||
} |
Oops, something went wrong.