Search code examples
c#dependency-injectionazure-functionspnp-core-sdk

Azure function dependency injection invocation exception


Update 02-03-2023 2

Found a workaround that fixed the issue for me. Posted it as an answer.

Update 02-03-2023 1

I have managed to pinpoint that the exception occurs when setting the certificate in PnPCoreAuthenticationCredentialConfigurationOptions

X509Certificate = new PnPCoreAuthenticationX509CertificateOptions
{
    Certificate = certificate
}

My theory at the moment is that the certificate data might not be persisted properly when Function1 is initialized with dependency injection.


I am building an Azure Function V4 using dotnet6 where I make use of PnPCore to authenticate to a SharePoint Online.

I authenticate to SharePoint Online using X509Certificate stored in an Azure Key Vault. Everything regarding the ADD registration, certificate, and key vault permissions are all fine. I have verified access to SharePoint Online works using PowerShell.

My Azure Function has a Startup class which binds the configuration and secrets to access SharePoint Online and does dependency injection.

However when the function executes I get this error message:

System.Private.CoreLib: Exception has been thrown by the target 
of an invocation.

Anonymously Hosted DynamicMethods Assembly:

Object reference not set to an instance of an object.

My local.settings.json:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet"
  },
  "SharePointApp": {
    "ClientId": "6f5020cb-xxxx-xxxx-xxxx-xxxxxxxxxxx",
    "ClientSecret": "A6q****l6",
    "TenantId": "7bcac91e-xxxx-xxxx-xxxx-xxxxxxxxxxx",
    "ListServerRelativeUrl": "/sites/TestSite/SomeLibrary",
    "AzureKeyVault": {
      "Uri": "https://contoso-keys.vault.azure.net/",
      "AppCertificateKey": "My-integration-app-DEV"
    }
  },
  "PnPCore": {
    "HttpRequests": {
      "UserAgent": "PnPCoreSDK"
    },
    "PnPContext": {
      "GraphFirst": "false",
      "GraphCanUseBeta": "false"
    },
    "Sites": {
      "SiteToWorkWith": {
        "SiteUrl": "https://contoso.sharepoint.com/sites/TestSite/"
      }
    }
  }
}

My Startup:

using Azure.Identity;
using Azure.Security.KeyVault.Certificates;
using Contoso.Function;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using PnP.Core.Auth.Services.Builder.Configuration;
using PnP.Core.Services.Builder.Configuration;
using System;

[assembly: FunctionsStartup(typeof(Startup))]
namespace Contoso.Function
{
    public class Startup : FunctionsStartup
    {
        private static IConfiguration _configuration = null;

        public override void Configure(IFunctionsHostBuilder builder)
        {
            var provider = builder.Services.BuildServiceProvider();
            _configuration = provider.GetRequiredService<IConfiguration>();

            var spOptions = new SharePointAppConfiguration(); // Custom class for "SharePointApp" in local.settings.json.
            _configuration.GetSection("SharePointApp").Bind(spOptions);
            var coreOptions = new PnPCoreOptions();
            _configuration.GetSection("PnPCore").Bind(coreOptions);

            builder.Services.AddSingleton(spOptions);

            builder.Services.AddPnPCore(options =>
            {
                options.PnPContext = coreOptions.PnPContext;
                options.HttpRequests = coreOptions.HttpRequests;

                foreach (var site in coreOptions.Sites)
                {
                    options.Sites.Add(site.Key, site.Value);
                }
            });

            builder.Services.AddPnPCoreAuthentication(options =>
            {
                var azKeyVault = new CertificateClient(spOptions.AzureKeyVault.Uri, new ClientSecretCredential(spOptions.TenantId, spOptions.ClientId, spOptions.ClientSecret));
                var certificate = azKeyVault.DownloadCertificate(spOptions.AzureKeyVault.AppCertificateKey);

                options.Credentials.Configurations.Add("x509certificate", new PnPCoreAuthenticationCredentialConfigurationOptions
                {
                    ClientId = spOptions.ClientId,
                    TenantId = spOptions.TenantId,
                    X509Certificate = new PnPCoreAuthenticationX509CertificateOptions
                    {
                        Certificate = certificate
                    }
                });
                options.Credentials.DefaultConfiguration = "x509certificate";
                options.Sites.Add("SiteToWorkWith",
                    new PnPCoreAuthenticationSiteOptions
                    {
                        AuthenticationProviderName = "x509certificate"
                    });
            });
        }

        public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
        {
            var context = builder.GetContext();

            builder.ConfigurationBuilder
                .SetBasePath(Environment.CurrentDirectory)
                .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
                .AddEnvironmentVariables();
        }
    }
}

My Function:

using System;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
using PnP.Core.Services;

namespace Contoso.Function
{
    public class Function1
    {
        private readonly IPnPContextFactory _pnpContextFactory;

        public Function1(IPnPContextFactory pnpContextFactory)
        {
            _pnpContextFactory = pnpContextFactory;
        }

        [FunctionName("Function1")]
        public void Run([TimerTrigger("*/5 * * * * *")] TimerInfo myTimer, ILogger log)
        {
            using (var context = _pnpContextFactory.CreateAsync("SitesToWorkWith"))
            {
                log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");
            }
        }
    }
}

As soon as I remove the builder.Services.AddPnPCoreAuthentication part the function runs fine and of course fails when getting to using (var context = _pnpContextFactory.CreateAsync("SitesToWorkWith")) because no authentication was provided.

Am I missing some vital part in Startup or is my local.settings.json missing some properties?


Solution

  • Found a solution that works for me. For some reason AddPnPCoreAuthentication did not work, so found an example from this sample code which explicitly sets an authentication provider in AddPnPCore instead.

    So my Startup ended up looking like this:

    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services
            .AddOptions<SharePointAppConfiguration>()
            .Configure<IConfiguration>((settings, configuration) =>
            {
                configuration.GetSection("SharePointApp").Bind(settings);
            });
    
        builder.Services
            .AddOptions<PnPCoreOptions>()
            .Configure<IConfiguration>((settings, configuration) =>
            {
                configuration.GetSection("PnPCore").Bind(settings);
            });
    
        var provider = builder.Services.BuildServiceProvider();
        var spOptions = provider.GetRequiredService<IOptions<SharePointAppConfiguration>>().Value;
        builder.Services.AddSingleton(spOptions);
    
        builder.Services.AddPnPCore(options =>
        {
            var azKeyVaultClient = new CertificateClient(spOptions.AzureKeyVault.Uri, new ClientSecretCredential(spOptions.TenantId, spOptions.ClientId, spOptions.ClientSecret));
            var certificate = azKeyVaultClient.DownloadCertificate(spOptions.AzureKeyVault.AppCertificateKey);
            var authProvider = new X509CertificateAuthenticationProvider(spOptions.ClientId, spOptions.TenantId, certificate)
            {
                ConfigurationName = "x509certificate"
            };
                    
            options.DefaultAuthenticationProvider = authProvider;
    
            foreach (var site in options.Sites)
            {
                site.Value.AuthenticationProvider = authProvider;
            }
        });
    }
    
    public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
    {
        builder.ConfigurationBuilder
            .SetBasePath(Environment.CurrentDirectory)
            .AddJsonFile("local.settings.json", true, true)
            .AddEnvironmentVariables();
    }