diff --git a/samples/ace-expense-report/Restaurant-Receipt.png b/samples/ace-expense-report/Restaurant-Receipt.png new file mode 100644 index 00000000..e8f4ab77 Binary files /dev/null and b/samples/ace-expense-report/Restaurant-Receipt.png differ diff --git a/samples/ace-expense-report/ace-expense-report-backend/ExpenseReport.cs b/samples/ace-expense-report/ace-expense-report-backend/ExpenseReport.cs new file mode 100644 index 00000000..69c718eb --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/ExpenseReport.cs @@ -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; } + } +} diff --git a/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/ClaimTypes.cs b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/ClaimTypes.cs new file mode 100644 index 00000000..09c39c22 --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/ClaimTypes.cs @@ -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"; + } +} \ No newline at end of file diff --git a/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthenticationMiddleware.cs b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthenticationMiddleware.cs new file mode 100644 index 00000000..ad563865 --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthenticationMiddleware.cs @@ -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 _configurationManager; + + private static Regex _tenantIdRegEx = new Regex(@"(?(https:\/\/sts\.windows\.net\/))(?((\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( + $"{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(); + + 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>(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; + } + } +} \ No newline at end of file diff --git a/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthorizationMiddleware.cs b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthorizationMiddleware.cs new file mode 100644 index 00000000..5cd5ba1a --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthorizationMiddleware.cs @@ -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(); + + if (principalFeature != null) + { + // Get the invoked function method + var targetMethod = context.GetTargetFunctionMethod(); + + // Get the FunctionAuthorize attribute, if any + var functionAuthorizeAttribute = targetMethod.GetCustomAttribute(); + + // 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(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; + } + } +} \ No newline at end of file diff --git a/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthorizeAttribute.cs b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthorizeAttribute.cs new file mode 100644 index 00000000..d3913181 --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionAuthorizeAttribute.cs @@ -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 +{ + /// + /// Custom attribute to provide custom authorization logic for Funtion App functions + /// + /// + /// This attribute can only be applied to methods + /// + [AttributeUsage(AttributeTargets.Method)] + internal class FunctionAuthorizeAttribute : Attribute + { + /// + /// Defines which scopes (aka delegated permissions) are accepted + /// + public string[] Scopes { get; set; } = Array.Empty(); + + /// + /// Defines which roles (aka application permissions) are accepted + /// + public string[] Roles { get; set; } = Array.Empty(); + + /// + /// Defines whether to run the request on-behalf-of the current user + /// + public bool RunOnBehalfOf { get; set; } + } +} diff --git a/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionContextExtensions.cs b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionContextExtensions.cs new file mode 100644 index 00000000..433c7c47 --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/FunctionContextExtensions.cs @@ -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; + } + } +} diff --git a/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/JwtPrincipalFeature.cs b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/JwtPrincipalFeature.cs new file mode 100644 index 00000000..25b8f98f --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/FunctionsMiddleware/JwtPrincipalFeature.cs @@ -0,0 +1,50 @@ +using System.Security.Claims; + +namespace PnP.Ace.ExpenseReport.Backend +{ + /// + /// Holds the authenticated user principal + /// for the request along with the + /// access token they used. + /// + public class JwtPrincipalFeature + { + public JwtPrincipalFeature(ClaimsPrincipal principal, string accessToken) + { + // Set principal and Access Token + Principal = principal; + AccessToken = accessToken; + + // Retrieve current Tenant ID + var tenantId = principal.FindFirst(c => c.Type == ClaimTypes.TenantIdClaimType); + TenantId = tenantId?.Value; + } + + public JwtPrincipalFeature(ClaimsPrincipal principal, string accessToken, string oboToken): + this(principal, accessToken) + { + // Set On-Behalf-Of Token + OnBehalfOfToken = oboToken; + } + + public ClaimsPrincipal Principal { get; } + + /// + /// The access token that was used for this + /// request. Can be used to acquire further + /// access tokens with the on-behalf-of flow. + /// + public string AccessToken { get; } + + /// + /// The access token to run on-behalf-of + /// the current user + /// + public string? OnBehalfOfToken { get; } + + /// + /// The ID of the current tenant in a multi-tenant solution + /// + public string? TenantId { get; } + } +} \ No newline at end of file diff --git a/samples/ace-expense-report/ace-expense-report-backend/OpenApiConfigurationOptions.cs b/samples/ace-expense-report/ace-expense-report-backend/OpenApiConfigurationOptions.cs new file mode 100644 index 00000000..01bb991e --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/OpenApiConfigurationOptions.cs @@ -0,0 +1,41 @@ +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Abstractions; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; +using Microsoft.OpenApi.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PnP.Ace.ExpenseReport.Backend +{ + public class OpenApiConfigurationOptions : IOpenApiConfigurationOptions + { + public OpenApiInfo Info { get; set; } = + new OpenApiInfo + { + Title = "PnP.Ace.ExpenseReport.Backend", + Version = "1.0", + Description = "PnP.Ace.ExpenseReport.Backend documentation", + Contact = new OpenApiContact() + { + Name = "PnP Expense Reports Demo", + Url = new Uri("https://pnp.github.io/"), + }, + //License = new OpenApiLicense() + //{ + // Name = "MIT", + // Url = new Uri("http://opensource.org/licenses/MIT"), + //} + }; + + public List Servers { get; set; } = new(); + + public OpenApiVersionType OpenApiVersion { get; set; } = OpenApiVersionType.V3; + + public bool IncludeRequestingHostName { get; set; } = false; + public bool ForceHttp { get; set; } = false; + public bool ForceHttps { get; set; } = false; + public List DocumentFilters { get; set; } = new(); + } +} diff --git a/samples/ace-expense-report/ace-expense-report-backend/ace-expense-report-backend.csproj b/samples/ace-expense-report/ace-expense-report-backend/PnP.Ace.ExpenseReport.Backend.csproj similarity index 72% rename from samples/ace-expense-report/ace-expense-report-backend/ace-expense-report-backend.csproj rename to samples/ace-expense-report/ace-expense-report-backend/PnP.Ace.ExpenseReport.Backend.csproj index 0d8c10e6..0d75bd88 100644 --- a/samples/ace-expense-report/ace-expense-report-backend/ace-expense-report-backend.csproj +++ b/samples/ace-expense-report/ace-expense-report-backend/PnP.Ace.ExpenseReport.Backend.csproj @@ -5,14 +5,19 @@ Exe enable enable - ace_expense_report_backend + + + + + + diff --git a/samples/ace-expense-report/ace-expense-report-backend/ace-expense-report-backend.sln b/samples/ace-expense-report/ace-expense-report-backend/PnP.Ace.ExpenseReport.Backend.sln similarity index 54% rename from samples/ace-expense-report/ace-expense-report-backend/ace-expense-report-backend.sln rename to samples/ace-expense-report/ace-expense-report-backend/PnP.Ace.ExpenseReport.Backend.sln index 30e914a7..58c2ffaf 100644 --- a/samples/ace-expense-report/ace-expense-report-backend/ace-expense-report-backend.sln +++ b/samples/ace-expense-report/ace-expense-report-backend/PnP.Ace.ExpenseReport.Backend.sln @@ -1,9 +1,9 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.7.34302.85 +VisualStudioVersion = 17.6.33829.357 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ace-expense-report-backend", "ace-expense-report-backend.csproj", "{DD1E46DB-B93D-4379-834B-719D5EA9EF9D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnP.Ace.ExpenseReport.Backend", "PnP.Ace.ExpenseReport.Backend.csproj", "{EA8FAB28-468B-40F7-A929-13D49B1CEA01}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,15 +11,15 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {DD1E46DB-B93D-4379-834B-719D5EA9EF9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DD1E46DB-B93D-4379-834B-719D5EA9EF9D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DD1E46DB-B93D-4379-834B-719D5EA9EF9D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DD1E46DB-B93D-4379-834B-719D5EA9EF9D}.Release|Any CPU.Build.0 = Release|Any CPU + {EA8FAB28-468B-40F7-A929-13D49B1CEA01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA8FAB28-468B-40F7-A929-13D49B1CEA01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA8FAB28-468B-40F7-A929-13D49B1CEA01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA8FAB28-468B-40F7-A929-13D49B1CEA01}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {08D10B3C-AFA3-4CA8-AF32-4384345EA1CE} + SolutionGuid = {81A96237-696F-4C22-9981-FAED1ADD38E1} EndGlobalSection EndGlobal diff --git a/samples/ace-expense-report/ace-expense-report-backend/Program.cs b/samples/ace-expense-report/ace-expense-report-backend/Program.cs index 9de3b3ed..91ac303f 100644 --- a/samples/ace-expense-report/ace-expense-report-backend/Program.cs +++ b/samples/ace-expense-report/ace-expense-report-backend/Program.cs @@ -1,14 +1,37 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using System.Text.Json.Serialization; +using System.Text.Json; +using PnP.Ace.ExpenseReport.Backend; var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .ConfigureServices(services => + .ConfigureServices((context, services) => { + // Define global JSON serializer options + services.Configure(options => + { + options.AllowTrailingCommas = true; + options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.PropertyNameCaseInsensitive = true; + }); + services.AddApplicationInsightsTelemetryWorkerService(); services.ConfigureFunctionsApplicationInsights(); }) + .ConfigureFunctionsWorkerDefaults((context, builder) => + { + // Credits to Joonas Westlin: https://github.com/juunas11/IsolatedFunctionsAuthentication + // I created my implementation starting from there, with some little touches (Application only, OBO flow, etc.) + builder.UseWhen(functionContext => + { + // Only use the middleware if not related to Swagger or OpenApi + return !functionContext.FunctionDefinition.Name.Contains("swagger", StringComparison.InvariantCultureIgnoreCase) && + !functionContext.FunctionDefinition.Name.Contains("openapi", StringComparison.InvariantCultureIgnoreCase); + }); + builder.UseMiddleware(); + }) .Build(); host.Run(); diff --git a/samples/ace-expense-report/ace-expense-report-backend/Properties/launchSettings.json b/samples/ace-expense-report/ace-expense-report-backend/Properties/launchSettings.json index be6b534a..f0f82029 100644 --- a/samples/ace-expense-report/ace-expense-report-backend/Properties/launchSettings.json +++ b/samples/ace-expense-report/ace-expense-report-backend/Properties/launchSettings.json @@ -1,8 +1,8 @@ { "profiles": { - "ace_expense_report_backend": { + "PnP.Ace.ExpenseReport.Backend": { "commandName": "Project", - "commandLineArgs": "--port 7042", + "commandLineArgs": "--port 7172", "launchBrowser": false } } diff --git a/samples/ace-expense-report/ace-expense-report-backend/SecurityHelper.cs b/samples/ace-expense-report/ace-expense-report-backend/SecurityHelper.cs new file mode 100644 index 00000000..dd2b7b14 --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/SecurityHelper.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Identity.Client; +using Microsoft.IdentityModel.Tokens; + +namespace PnP.Ace.ExpenseReport.Backend +{ + internal static class SecurityHelper { + + public static async Task GetOboToken(string bearerToken, string tenantId){ + + // Initialize configuration variables + var clientId = Environment.GetEnvironmentVariable("ClientId"); + if (string.IsNullOrEmpty(tenantId)) { + var tenantIdDefaultValue = Environment.GetEnvironmentVariable("TenantId"); + if (tenantIdDefaultValue != null) + { + tenantId = tenantIdDefaultValue; + } + else + { + throw new Exception("TenantId is not provided"); + } + } + var clientSecret = Environment.GetEnvironmentVariable("ClientSecret"); + var scopesString = Environment.GetEnvironmentVariable("Scopes"); + var scopes = scopesString?.Split(','); + + // Create the MSAL confidential client application for On-Behalf-Of flow + var confidentialClientApplication = ConfidentialClientApplicationBuilder + .Create(clientId) + .WithTenantId(tenantId) + .WithClientSecret(clientSecret) + .Build(); + + // Prepare the user assertion based on the received Access Token + var assertion = new UserAssertion(bearerToken); + + // Try to get the token from the tokens cache + var tokenResult = await confidentialClientApplication + .AcquireTokenOnBehalfOf(scopes, assertion) + .ExecuteAsync().ConfigureAwait(false); + + // Provide back the OBO Access Token + return tokenResult.AccessToken; + } + + public static string ComputeHash(string input) + { + // Compute a SHA256 hash on the input + using (SHA256 sha256Hash = SHA256.Create()) + { + // ComputeHash - returns byte array + byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(input)); + + // Convert byte array to a string + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < bytes.Length; i++) + { + builder.Append(bytes[i].ToString("x2")); + } + return builder.ToString(); + } + } + } +} \ No newline at end of file diff --git a/samples/ace-expense-report/ace-expense-report-backend/SimpleGraphClient.cs b/samples/ace-expense-report/ace-expense-report-backend/SimpleGraphClient.cs new file mode 100644 index 00000000..91081a37 --- /dev/null +++ b/samples/ace-expense-report/ace-expense-report-backend/SimpleGraphClient.cs @@ -0,0 +1,100 @@ +using Microsoft.Graph; +using Microsoft.Graph.Models; +using Microsoft.Kiota.Abstractions.Authentication; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PnP.Ace.ExpenseReport.Backend +{ + // This class is a wrapper for the Microsoft Graph API + // See: https://developer.microsoft.com/en-us/graph + public class SimpleGraphClient + { + private readonly string _token; + + public SimpleGraphClient(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + throw new ArgumentNullException(nameof(token)); + } + + _token = token; + } + + public async Task SaveExpenseReportFile(ExpenseReport expenseReport, Stream fileStream) + { + var targetSiteId = Environment.GetEnvironmentVariable("TargetSiteId"); + var targetLibraryId = Environment.GetEnvironmentVariable("TargetLibraryId"); + + try + { + var graphClient = GetAuthenticatedClient(); + + var drive = await graphClient.Sites[targetSiteId] + .Drives[targetLibraryId] + .GetAsync(o => o.QueryParameters.Expand = new string[] { "list" }); + + if (drive != null && drive.List != null) + { + var targetFileName = $"{Guid.NewGuid()}-{expenseReport.ReceiptFileName}"; + + var uploadedFile = await graphClient.Drives[drive.Id] + .Root.ItemWithPath(targetFileName).Content + .PutAsync(fileStream); + + var uploadedFileItem = await graphClient.Drives[drive.Id] + .Root.ItemWithPath(targetFileName).GetAsync(o => o.QueryParameters.Expand = new string[] { "listItem" }); + + if (uploadedFile != null && uploadedFileItem != null && uploadedFileItem.ListItem != null) + { + var fieldValues = new FieldValueSet + { + AdditionalData = new Dictionary { + { "PnPExpenseReportDescription", expenseReport.Description }, + { "PnPExpenseReportCategory", expenseReport.Category }, + { "PnPExpenseReportDate", expenseReport.Date}, + } + }; + + await graphClient.Sites[targetSiteId] + .Lists[drive.List.Id] + .Items[uploadedFileItem.ListItem.Id] + .Fields.PatchAsync(fieldValues); + } + } + } + catch (Exception ex) + { + + } + } + + // Get an Authenticated Microsoft Graph client using the token issued to the user. + private GraphServiceClient GetAuthenticatedClient() + { + var graphClient = new GraphServiceClient(new BaseBearerTokenAuthenticationProvider(new TokenProvider(_token))); + return graphClient; + } + } + + public class TokenProvider : IAccessTokenProvider + { + private string _token { get; set; } + + public TokenProvider(string token) + { + _token = token; + } + + public AllowedHostsValidator AllowedHostsValidator => throw new NotImplementedException(); + + public Task GetAuthorizationTokenAsync(Uri uri, Dictionary? additionalAuthenticationContext = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(_token); + } + } +} diff --git a/samples/ace-expense-report/ace-expense-report-backend/UploadExpenseReport.cs b/samples/ace-expense-report/ace-expense-report-backend/UploadExpenseReport.cs index 902faa47..71ab85b7 100644 --- a/samples/ace-expense-report/ace-expense-report-backend/UploadExpenseReport.cs +++ b/samples/ace-expense-report/ace-expense-report-backend/UploadExpenseReport.cs @@ -1,9 +1,13 @@ using System.Net; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; +using Newtonsoft.Json; -namespace ace_expense_report_backend +namespace PnP.Ace.ExpenseReport.Backend { public class UploadExpenseReport { @@ -15,10 +19,44 @@ public UploadExpenseReport(ILoggerFactory loggerFactory) } [Function("UploadExpenseReport")] - public HttpResponseData Run([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) + [OpenApiOperation(operationId: "UploadExpenseReport", tags: new[] { "Expense Reports" })] + [OpenApiRequestBody(contentType: "application/json", bodyType: typeof(ExpenseReport), Required = true, Description = "The Expense Report object")] + [OpenApiSecurity("bearer_auth", SecuritySchemeType.Http, Scheme = OpenApiSecuritySchemeType.Bearer, BearerFormat = "JWT")] + [FunctionAuthorize(Scopes = new string[] { "ExpenseReport.Create" }, RunOnBehalfOf = true)] + public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", "options")] HttpRequestData req) { _logger.LogInformation("C# HTTP trigger function processed a request."); + try + { + string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); + var model = JsonConvert.DeserializeObject(requestBody); + + // Convert the file content into a MemoryStream + var fileContent = model.ReceiptContent.Substring(model.ReceiptContent.IndexOf(";base64,") + 8); + byte[] byteArray = Convert.FromBase64String(fileContent); + using (MemoryStream memoryStream = new MemoryStream(byteArray)) + { + // Now use Microsoft Graph SDK to store the file in SPO + + // Get the OBO token for Microsoft Graph + var principal = req.FunctionContext.Features.Get(); + var graphAccessToken = principal?.OnBehalfOfToken; + + if (graphAccessToken != null) + { + var client = new SimpleGraphClient(graphAccessToken); + await client.SaveExpenseReportFile(model, memoryStream); + } + } + + _logger.LogInformation(requestBody); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error!"); + } + var response = req.CreateResponse(HttpStatusCode.OK); response.Headers.Add("Content-Type", "text/plain; charset=utf-8"); diff --git a/samples/ace-expense-report/ace-expense-report-spfx/config/package-solution.json b/samples/ace-expense-report/ace-expense-report-spfx/config/package-solution.json index be902895..bdb566d7 100644 --- a/samples/ace-expense-report/ace-expense-report-spfx/config/package-solution.json +++ b/samples/ace-expense-report/ace-expense-report-spfx/config/package-solution.json @@ -3,17 +3,23 @@ "solution": { "name": "ace-expense-report-spfx-client-side-solution", "id": "7974f935-176b-4b14-b4b7-b6cf9dd8cc58", - "version": "1.0.0.0", + "version": "1.0.0.1", "includeClientSideAssets": true, "skipFeatureDeployment": true, "isDomainIsolated": false, "developer": { - "name": "", - "websiteUrl": "", - "privacyUrl": "", - "termsOfUseUrl": "", - "mpnId": "Undefined-1.18.2" + "name": "PiaSys.com", + "websiteUrl": "https://www.piasys.com/", + "privacyUrl": "http://piasys.com/privacy/", + "termsOfUseUrl": "https://www.piasys.com/terms-of-use/", + "mpnId": "1075379" }, + "webApiPermissionRequests": [ + { + "resource": "PnP.ExpenseReport.Service", + "scope": "ExpenseReport.Create" + } + ], "metadata": { "shortDescription": { "default": "ace-expense-report-spfx description" diff --git a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/ExpenseReportAdaptiveCardExtension.ts b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/ExpenseReportAdaptiveCardExtension.ts index 423430c3..992c2ea4 100644 --- a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/ExpenseReportAdaptiveCardExtension.ts +++ b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/ExpenseReportAdaptiveCardExtension.ts @@ -15,6 +15,9 @@ export interface IExpenseReportAdaptiveCardExtensionProps { } export interface IExpenseReportAdaptiveCardExtensionState { + expenseDescription?: string; + expenseCategory?: string; + expenseDate?: string; expenseReceiptFileName?: string; expenseReceiptContent?: string; } diff --git a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/assets/PiledCoins.png b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/assets/PiledCoins.png new file mode 100644 index 00000000..b47cb560 Binary files /dev/null and b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/assets/PiledCoins.png differ diff --git a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/cardView/HomeCardView.ts b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/cardView/HomeCardView.ts index dcdcc7bf..796eeb97 100644 --- a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/cardView/HomeCardView.ts +++ b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/cardView/HomeCardView.ts @@ -1,7 +1,7 @@ import { BaseComponentsCardView, ComponentsCardViewParameters, - BasicCardView, + ImageCardView, IExternalLinkCardAction, IQuickViewCardAction } from '@microsoft/sp-adaptive-card-extension-base'; @@ -18,11 +18,15 @@ export class HomeCardView extends BaseComponentsCardView< ComponentsCardViewParameters > { public get cardViewParameters(): ComponentsCardViewParameters { - return BasicCardView({ + return ImageCardView({ cardBar: { componentName: 'cardBar', title: this.properties.title }, + image: { + url: require('../assets/PiledCoins.png'), + altText: 'Placeholder image' + }, header: { componentName: 'text', text: strings.PrimaryText diff --git a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/loc/en-us.js b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/loc/en-us.js index 3d66224b..081da5d6 100644 --- a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/loc/en-us.js +++ b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/loc/en-us.js @@ -9,7 +9,7 @@ define([], function() { PrimaryText: "Use this card to upload your expenese reports", ConfirmationText: "Your exenese report has been submitted", Description: "Create your SPFx Adaptive Card Extension solution!", - NewExpense: "Quick view", + NewExpense: "New Expense", GoHome: "Home", ExpenseReport: { DescriptionLabel: "Description", diff --git a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/quickView/UploadQuickView.ts b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/quickView/UploadQuickView.ts index f1188f5f..251c8b97 100644 --- a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/quickView/UploadQuickView.ts +++ b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/quickView/UploadQuickView.ts @@ -20,6 +20,9 @@ export interface IUploadQuickViewData { expenseDateRequiredMessage: string; expenseUploadButtonLabel: string; submitButtonLabel: string; + expenseDescription?: string; + expenseCategory?: string; + expenseDate?: string; } export class UploadQuickView extends BaseAdaptiveCardQuickView< @@ -45,7 +48,10 @@ export class UploadQuickView extends BaseAdaptiveCardQuickView< expenseDatePlaceholder: strings.ExpenseReport.DatePlaceholder, expenseDateRequiredMessage: strings.ExpenseReport.DateRequiredMessage, expenseUploadButtonLabel: strings.ExpenseReport.UploadButtonLabel, - submitButtonLabel: strings.ExpenseReport.SubmitExpenseButtonLabel + submitButtonLabel: strings.ExpenseReport.SubmitExpenseButtonLabel, + expenseDescription: this.state.expenseDescription ?? "", + expenseCategory: this.state.expenseCategory ?? "", + expenseDate: this.state.expenseDate ?? "" }; } @@ -59,23 +65,36 @@ export class UploadQuickView extends BaseAdaptiveCardQuickView< console.log(action.media); // media is an array of attachment objects which contain the content and filename this.setState({ + expenseDescription: action.data.expenseDescription, + expenseCategory: action.data.expenseCategory, + expenseDate: action.data.expenseDate, expenseReceiptFileName: action.media[0].fileName, expenseReceiptContent: action.media[0].content // base64 encoded string }); } else if (action.type === 'Submit' && action.id === 'submitExpense') { - // Create the expense report - await this.properties.createExpenseReport({ - description: action.data.expenseDescription, - category: action.data.expenseCategory, - date: action.data.expenseDate, - receiptFileName: this.state.expenseReceiptFileName, - receiptContent: this.state.expenseReceiptContent - }); + if (this.state.expenseReceiptFileName && this.state.expenseReceiptContent) { + // Create the expense report + await this.properties.createExpenseReport({ + description: action.data.expenseDescription, + category: action.data.expenseCategory, + date: action.data.expenseDate, + receiptFileName: this.state.expenseReceiptFileName, + receiptContent: this.state.expenseReceiptContent + }); + + this.setState({ + expenseDescription: undefined, + expenseCategory: undefined, + expenseDate: undefined, + expenseReceiptFileName: undefined, + expenseReceiptContent: undefined + }); - (>>this.quickViewNavigator).close(); - this.cardNavigator.push(CARD_VIEW_CONFIRM_REGISTRY_ID); + (>>this.quickViewNavigator).close(); + this.cardNavigator.push(CARD_VIEW_CONFIRM_REGISTRY_ID); + } } } } diff --git a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/quickView/template/UploadQuickViewTemplate.json b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/quickView/template/UploadQuickViewTemplate.json index 676030e1..8776b69b 100644 --- a/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/quickView/template/UploadQuickViewTemplate.json +++ b/samples/ace-expense-report/ace-expense-report-spfx/src/adaptiveCardExtensions/expenseReport/quickView/template/UploadQuickViewTemplate.json @@ -18,13 +18,15 @@ "label": "${expenseDescriptionLabel}", "isRequired": true, "errorMessage": "${expenseDescriptionRequiredMessage}", - "placeholder": "${expenseDescriptionPlaceholder}" + "placeholder": "${expenseDescriptionPlaceholder}", + "value": "${expenseDescription}" }, { "type": "Input.ChoiceSet", "id": "expenseCategory", "label": "${expenseCategoryLabel}", "placeholder": "${expneseCategoryPlaceholder}", + "value": "${expenseCategory}", "choices": [ { "$data": "${expenseCategories}", @@ -38,7 +40,8 @@ "id": "expenseDate", "label": "${expenseDateLabel}", "placeholder": "${expenseDatePlaceholder}", - "errorMessage": "${expenseDateRequiredMessage}" + "errorMessage": "${expenseDateRequiredMessage}", + "value": "${expenseDate}" } ] } @@ -51,7 +54,7 @@ "parameters": { "mediaType": "MediaType.Image", "allowMultipleCapture": false, - "supportedFileFormats": "png jpg jpeg pdf" + "supportedFileFormats": "png jpg jpeg" } }, { diff --git a/samples/ace-expense-report/assets/ACE-QuickView-Mobile.PNG b/samples/ace-expense-report/assets/ACE-QuickView-Mobile.PNG new file mode 100644 index 00000000..4bb194c7 Binary files /dev/null and b/samples/ace-expense-report/assets/ACE-QuickView-Mobile.PNG differ diff --git a/samples/ace-expense-report/assets/Viva-Connections-Dashboard-Desktop.png b/samples/ace-expense-report/assets/Viva-Connections-Dashboard-Desktop.png new file mode 100644 index 00000000..02ae270a Binary files /dev/null and b/samples/ace-expense-report/assets/Viva-Connections-Dashboard-Desktop.png differ diff --git a/samples/ace-expense-report/assets/Viva-Connections-Dashboard-Mobile.PNG b/samples/ace-expense-report/assets/Viva-Connections-Dashboard-Mobile.PNG new file mode 100644 index 00000000..af8ae258 Binary files /dev/null and b/samples/ace-expense-report/assets/Viva-Connections-Dashboard-Mobile.PNG differ diff --git a/samples/ace-expense-report/assets/sample.json b/samples/ace-expense-report/assets/sample.json new file mode 100644 index 00000000..58e09210 --- /dev/null +++ b/samples/ace-expense-report/assets/sample.json @@ -0,0 +1,80 @@ +[ + { + "name": "pnp-spfx-reference-scenarios-samples-ace-expense-report", + "source": "pnp", + "title": "Expense Reports ACE", + "shortDescription": "This is a sample end-to-end application, to see how to create a Microsoft Viva Connections Adaptive Card Extension for expense reports that uploads receipts via Microsoft Graph through a back-end Azure Function secured with Microsoft Entra ID.", + "url": "https://github.com/pnp/spfx-reference-scenarios/tree/main/samples/ace-expense-report/", + "longDescription": [ + "This is a sample end-to-end application, to see how to create a Microsoft Viva Connections Adaptive Card Extension for expense reports that uploads receipts via Microsoft Graph through a back-end Azure Function secured with Microsoft Entra ID." + ], + "creationDateTime": "2024-01-11", + "updateDateTime": "2024-01-11", + "products": [ + "SharePoint", + "Viva", + "Microsoft Teams" + ], + "metadata": [ + { + "key": "CLIENT-SIDE-DEV", + "value": "TypeScript,React" + }, + { + "key": "SPFX-VERSION", + "value": "1.18.2" + } + ], + "thumbnails": [ + { + "type": "image", + "order": 100, + "url": "https://raw.githubusercontent.com/pnp/spfx-reference-scenarios/main/samples/ace-expense-report/assets/Viva-Connections-Dashboard-Desktop.png", + "alt": "The Microsoft Viva Connections Dashboard on the desktop" + }, + { + "type": "image", + "order": 200, + "url": "https://raw.githubusercontent.com/pnp/spfx-reference-scenarios/main/samples/ace-expense-report/assets/Viva-Connections-Dashboard-Mobile.PNG", + "alt": "The Microsoft Viva Connections Dashboard on mobile" + }, + { + "type": "image", + "order": 300, + "url": "https://raw.githubusercontent.com/pnp/spfx-reference-scenarios/main/samples/ace-expense-report/assets/ACE-QuickView-Mobile.PNG", + "alt": "The ACE Quick View on mobile" + } + ], + "authors": [ + { + "gitHubAccount": "PaoloPia", + "company": "PiaSys.com", + "pictureUrl": "https://github.com/paolopia.png", + "name": "Paolo Pialorsi", + "twitter": "PaoloPia" + } + ], + "references": [ + { + "name": "Getting started with SharePoint Framework", + "description": "Introduction about how to develop Microsoft 365 extensions using SharePoint Framework.", + "url": "https://docs.microsoft.com/en-us/sharepoint/dev/spfx/set-up-your-developer-tenant" + }, + { + "name": "Build your first SharePoint Adaptive Card Extension", + "description": "Learn how to develop your first Adaptive Card Extension (ACE) using SharePoint Framework (SPFx).", + "url": "https://docs.microsoft.com/en-us/sharepoint/dev/spfx/viva/get-started/build-first-sharepoint-adaptive-card-extension" + }, + { + "name": "Use Microsoft Graph in your solution", + "description": "Learn how to consume Microsoft Graph using SharePoint Framework (SPFx).", + "url": "https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/get-started/using-microsoft-graph-apis" + }, + { + "name": "Microsoft 365 Patterns and Practices", + "description": "Guidance, tooling, samples and open-source controls for your Microsoft 365 development.", + "url": "https://aka.ms/m365pnp" + } + ] + } + ] \ No newline at end of file diff --git a/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/appPackage/build/appPackage.local.zip b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/appPackage/build/appPackage.local.zip new file mode 100644 index 00000000..f01f6424 Binary files /dev/null and b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/appPackage/build/appPackage.local.zip differ diff --git a/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/appPackage/build/manifest.local.json b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/appPackage/build/manifest.local.json new file mode 100644 index 00000000..42badac7 --- /dev/null +++ b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/appPackage/build/manifest.local.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", + "manifestVersion": "1.16", + "version": "1.0.0", + "id": "b05d0b14-4631-481c-be3b-45dd6a44cdcf", + "packageName": "com.microsoft.teams.extension", + "developer": { + "name": "PiaSys.com", + "websiteUrl": "https://www.piasys.com/", + "privacyUrl": "https://piasys.com/privacy/", + "termsOfUseUrl": "https://www.piasys.com/terms-of-use/" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "contoso-retail-teams-local", + "full": "Contoso Retail Teams Extension" + }, + "description": { + "short": "Contoso Retail Teams Extension", + "full": "Contoso Retail Teams Extension" + }, + "accentColor": "#FFFFFF", + "bots": [], + "composeExtensions": [ + { + "botId": "3ebdbb70-48e9-479f-a13b-9401053f4e63", + "commands": [ + { + "id": "searchQuery", + "context": [ + "compose", + "commandBox" + ], + "description": "Search Contoso Retail", + "title": "Search", + "type": "query", + "parameters": [ + { + "name": "searchQuery", + "title": "Search Query", + "description": "Your search query", + "inputType": "text" + } + ] + } + ] + } + ], + "configurableTabs": [], + "staticTabs": [], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [ + "180lp79w-3978.euw.devtunnels.ms" + ], + "webApplicationInfo": { + "id": "cf0922dc-076a-4bfb-a1f5-c1ed0ecbad68", + "resource": "api://botid-3ebdbb70-48e9-479f-a13b-9401053f4e63" + } +} \ No newline at end of file diff --git a/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.dev.user b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.dev.user new file mode 100644 index 00000000..46f98fc3 --- /dev/null +++ b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.dev.user @@ -0,0 +1,5 @@ +# This file includes environment variables that will not be committed to git by default. You can set these environment variables in your CI/CD system for your project. + +# If you're adding a secret value, add SECRET_ prefix to the name so Teams Toolkit can handle them properly +# Secrets. Keys prefixed with `SECRET_` will be masked in Teams Toolkit logs. +SECRET_BOT_PASSWORD= \ No newline at end of file diff --git a/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.local b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.local new file mode 100644 index 00000000..3abc2103 --- /dev/null +++ b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.local @@ -0,0 +1,19 @@ +# This file includes environment variables that can be committed to git. It's gitignored by default because it represents your local development environment. + +# Built-in environment variables +TEAMSFX_ENV=local + +# Generated during provision, you can also add your own variables. +BOT_ID=3ebdbb70-48e9-479f-a13b-9401053f4e63 +TEAMS_APP_ID=b05d0b14-4631-481c-be3b-45dd6a44cdcf +BOT_DOMAIN=180lp79w-3978.euw.devtunnels.ms +BOT_ENDPOINT=https://180lp79w-3978.euw.devtunnels.ms +TEAMS_APP_TENANT_ID=6c94075a-da0a-4c6a-8411-badf652e8b53 +AAD_APP_CLIENT_ID=cf0922dc-076a-4bfb-a1f5-c1ed0ecbad68 +AAD_APP_OBJECT_ID=3d63495a-4f94-4c9a-bc81-f15b4cb2569a +AAD_APP_TENANT_ID=6c94075a-da0a-4c6a-8411-badf652e8b53 +AAD_APP_OAUTH_AUTHORITY=https://login.microsoftonline.com/6c94075a-da0a-4c6a-8411-badf652e8b53 +AAD_APP_OAUTH_AUTHORITY_HOST=https://login.microsoftonline.com +AAD_APP_ACCESS_AS_USER_PERMISSION_ID=c90011b1-5279-48ca-ac0b-55567027478b +M365_TITLE_ID=U_d19b1c06-ab0f-0b08-9aab-f02ee5478fb4 +M365_APP_ID=5b4e0b6c-b0dc-4eb4-8f7f-c8378719bd53 \ No newline at end of file diff --git a/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.local.user b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.local.user new file mode 100644 index 00000000..2068985e --- /dev/null +++ b/samples/contoso-retail-demo/contoso-retail-teams/contoso-retail-teams/env/.env.local.user @@ -0,0 +1,7 @@ +# This file includes environment variables that will not be committed to git by default. You can set these environment variables in your CI/CD system for your project. + +# If you're adding a secret value, add SECRET_ prefix to the name so Teams Toolkit can handle them properly +# Secrets. Keys prefixed with `SECRET_` will be masked in Teams Toolkit logs. +SECRET_BOT_PASSWORD=crypto_cb25e2c1661b703b66e5a7bf4c55d9dbbc1eebf555c7c41fae6bd84003fdee38e7e30cb63ab0b146b3402f8f45965012ea8ae49d37d854ff67cc12f945aeb244f3f25db917ec8e454c191e010bad694fd85c6252e6a0ea6d80138702d20ab084d0d88bb554d90856d6d153a917148db490a560138537cffeaef6368a827ab5b61e700c854a59a798 +SECRET_AAD_APP_CLIENT_SECRET=crypto_151b391df732a8c378c9757d317cb66e144395396109b3fbbdf7092d1091d3e68bc5307a4b77e1a2ee1d7e13fef585310de80d6be6ed003a62ed4f21e498dd39f0aa39d7c7ecb11982e5e45b85eaaaf0e37f60a5f4ed5cae01a17f5bb31712592c9601936a7412bc0bc4acdf90b44f954f71829e5f8e3d61f21a2cc8298d928bc1d75377dd51ce23 +TEAMS_APP_UPDATE_TIME=2023-10-15T16:09:37.6891837+00:00 \ No newline at end of file diff --git a/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.dll b/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.dll index 658fc67a..475708a9 100644 Binary files a/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.dll and b/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.dll differ diff --git a/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.exe b/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.exe index cf24cdec..800d2d9d 100644 Binary files a/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.exe and b/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.exe differ diff --git a/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.pdb b/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.pdb index e80446e1..1bf702c7 100644 Binary files a/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.pdb and b/samples/spfx-middleware/spfx-middleware-apis/bin/Debug/net6.0/spfx-middleware-apis.pdb differ diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/apphost.exe b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/apphost.exe index cf24cdec..800d2d9d 100644 Binary files a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/apphost.exe and b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/apphost.exe differ diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.assets.cache b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.assets.cache index 87a1d3e3..bdddfd53 100644 Binary files a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.assets.cache and b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.assets.cache differ diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.csproj.CoreCompileInputs.cache b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.csproj.CoreCompileInputs.cache index c906ff6e..74a74cb5 100644 --- a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.csproj.CoreCompileInputs.cache +++ b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.csproj.CoreCompileInputs.cache @@ -1 +1 @@ -c75af48a10482c6f6719bbafe241342ecd06847a +1d60c2cc5b99af6ead1d329a36ab99ee51b4b063 diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.dll b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.dll index 658fc67a..475708a9 100644 Binary files a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.dll and b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.dll differ diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.pdb b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.pdb index e80446e1..1bf702c7 100644 Binary files a/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.pdb and b/samples/spfx-middleware/spfx-middleware-apis/obj/Debug/net6.0/spfx-middleware-apis.pdb differ diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/project.assets.json b/samples/spfx-middleware/spfx-middleware-apis/obj/project.assets.json index c28e02f6..e9a10f6a 100644 --- a/samples/spfx-middleware/spfx-middleware-apis/obj/project.assets.json +++ b/samples/spfx-middleware/spfx-middleware-apis/obj/project.assets.json @@ -2892,8 +2892,7 @@ ] }, "packageFolders": { - "C:\\Users\\paolo\\.nuget\\packages\\": {}, - "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {} + "C:\\Users\\paolo\\.nuget\\packages\\": {} }, "project": { "version": "1.0.0", @@ -2904,12 +2903,8 @@ "packagesPath": "C:\\Users\\paolo\\.nuget\\packages\\", "outputPath": "C:\\github\\spfx-reference-scenarios\\samples\\spfx-middleware\\spfx-middleware-apis\\obj\\", "projectStyle": "PackageReference", - "fallbackFolders": [ - "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages" - ], "configFilePaths": [ "C:\\Users\\paolo\\AppData\\Roaming\\NuGet\\NuGet.Config", - "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config", "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config" ], "originalTargetFrameworks": [ @@ -2917,8 +2912,7 @@ ], "sources": { "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {}, - "C:\\Program Files\\dotnet\\library-packs": {}, - "C:\\Program Files\\dotnet\\sdk\\7.0.304\\Sdks\\Microsoft.NET.Sdk.Web\\library-packs": {}, + "C:\\Program Files\\dotnet\\sdk\\7.0.306\\Sdks\\Microsoft.NET.Sdk.Web\\library-packs": {}, "https://api.nuget.org/v3/index.json": {}, "https://nuget.pkg.github.com/LegaService/index.json": {}, "https://nuget.pkg.github.com/PiaSys/index.json": {} @@ -2983,7 +2977,7 @@ "privateAssets": "all" } }, - "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\7.0.304\\RuntimeIdentifierGraph.json" + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\7.0.306\\RuntimeIdentifierGraph.json" } } } diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/project.nuget.cache b/samples/spfx-middleware/spfx-middleware-apis/obj/project.nuget.cache index c1289783..e10cb32c 100644 --- a/samples/spfx-middleware/spfx-middleware-apis/obj/project.nuget.cache +++ b/samples/spfx-middleware/spfx-middleware-apis/obj/project.nuget.cache @@ -1,6 +1,6 @@ { "version": 2, - "dgSpecHash": "8ZqoJxINmOSi3BPYclbfc7U57cdXrpKch3ABsk5QK+kNsh5hIo9qilvoGvZ02LX6sDOQwEaHkF/KQYTYB2dCKA==", + "dgSpecHash": "PzL6GBciJ4wGbREWkGFYBOiq0W4Jkd+ypkhistLJjQSr18xfs07g3OQYFFXHipvKXAJNrhFNzhrhMJiNOHOlWg==", "success": true, "projectFilePath": "C:\\github\\spfx-reference-scenarios\\samples\\spfx-middleware\\spfx-middleware-apis\\spfx-middleware-apis.csproj", "expectedPackageFiles": [ diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/spfx-middleware-apis.csproj.nuget.dgspec.json b/samples/spfx-middleware/spfx-middleware-apis/obj/spfx-middleware-apis.csproj.nuget.dgspec.json index 21a9f58e..3edd402c 100644 --- a/samples/spfx-middleware/spfx-middleware-apis/obj/spfx-middleware-apis.csproj.nuget.dgspec.json +++ b/samples/spfx-middleware/spfx-middleware-apis/obj/spfx-middleware-apis.csproj.nuget.dgspec.json @@ -13,12 +13,8 @@ "packagesPath": "C:\\Users\\paolo\\.nuget\\packages\\", "outputPath": "C:\\github\\spfx-reference-scenarios\\samples\\spfx-middleware\\spfx-middleware-apis\\obj\\", "projectStyle": "PackageReference", - "fallbackFolders": [ - "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages" - ], "configFilePaths": [ "C:\\Users\\paolo\\AppData\\Roaming\\NuGet\\NuGet.Config", - "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config", "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config" ], "originalTargetFrameworks": [ @@ -26,8 +22,7 @@ ], "sources": { "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {}, - "C:\\Program Files\\dotnet\\library-packs": {}, - "C:\\Program Files\\dotnet\\sdk\\7.0.304\\Sdks\\Microsoft.NET.Sdk.Web\\library-packs": {}, + "C:\\Program Files\\dotnet\\sdk\\7.0.306\\Sdks\\Microsoft.NET.Sdk.Web\\library-packs": {}, "https://api.nuget.org/v3/index.json": {}, "https://nuget.pkg.github.com/LegaService/index.json": {}, "https://nuget.pkg.github.com/PiaSys/index.json": {} @@ -92,7 +87,7 @@ "privateAssets": "all" } }, - "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\7.0.304\\RuntimeIdentifierGraph.json" + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\7.0.306\\RuntimeIdentifierGraph.json" } } } diff --git a/samples/spfx-middleware/spfx-middleware-apis/obj/spfx-middleware-apis.csproj.nuget.g.props b/samples/spfx-middleware/spfx-middleware-apis/obj/spfx-middleware-apis.csproj.nuget.g.props index 6b237894..6aa91202 100644 --- a/samples/spfx-middleware/spfx-middleware-apis/obj/spfx-middleware-apis.csproj.nuget.g.props +++ b/samples/spfx-middleware/spfx-middleware-apis/obj/spfx-middleware-apis.csproj.nuget.g.props @@ -5,13 +5,12 @@ NuGet $(MSBuildThisFileDirectory)project.assets.json $(UserProfile)\.nuget\packages\ - C:\Users\paolo\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages + C:\Users\paolo\.nuget\packages\ PackageReference 6.6.0 -