authenticationactive-directoryasp.net-core-mvcdocusignapimicrosoft-entra-id

ASP.NET Core MVC with multiple authentication needs Oauth


When building a new internal solution (using ASP.NET Core MVC), our policy is to authenticate using our Active Directory (aka Entra) for our users. This new app then gathers data that then generates a DocuSign object using their API/SDKs. This is my first time needing to call APIs via SDK, and I am a bit confused by the mechanics. I want to follow the best security practices. DocuSign recommends that when the user is present to use Confidential Authorization Code Grant, and provides examples. However, I do not think the examples that modify Program.cs (examples show using older .NET versions’ Startup.cs) work when combined with Entra Authentication.

So in short, this solution should work this way:

  1. All users authenticate via Entra ID (Azure AD). (See yellow highlight)
  2. Once authenticated, users can perform actions that may require calling DocuSign APIs using OAuth 2.0 Authorization Code Flow.

My questions (after update, still would like to understand first 2 questions):

  1. I believe (but am not 100% sure) that Entra does not use cookie authentication. And we should not change that.

  2. Is it possible to have multiple Authentications in a single app as I described using Program.cs AddAuthentication()? What is the purpose of EnableTokenAcquisitionToCallDownstreamApi(), is it to use the user's credentials to get other data that those credentials allow?

  3. (Answered with update- Found another way to make this work) I believe I should bag trying to AddAuthentication() in Program.cs, instead creating HttpClient requests, writing code in controllers to get the access token and then somehow persist that to call the other DocuSign APis. I thought I would find a examples of this, but I mostly see examples using JWT Tokens, for api to api calls. True? Answer: Yes

Update: Ultimately, did remove the second AddAuthentication() from Program.cs:

// Add Entra authentication
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
//pulled out all the other AddAuth, AddOauth... 

I use my controllers to call Docusign SDK. There is one document that at the bottom, has the class and methods that worked: https://developers.docusign.com/platform/auth/confidential-authcode-get-token/

Used DocuSignClient.cs 3 methods in my controller layer. (See their class code here https://github.com/docusign/docusign-esign-csharp-client/blob/master/sdk/src/DocuSign.eSign/Client/DocuSignClient.cs)

GetAuthorizationUri(), GenerateAccessToken(),GetUserInfo()

It worked. Maybe DocuSign will provide examples and samples using these, I did not find any myself, but it was fairly straightforward. It was not in their QuickStart code.

Original: When I try the example code (snippets below), I successfully authenticate with Entra, and when I navigate to the controller with DocuSign authentication, I get prompted for my DocuSign credentials. All good. With a breakpoint in the OnCreatingTicket, I get through this part of authentication. However, after that, I get an exception below. All the research on it says I need to enable cookies in my default authentication scheme. Which I do not want to do.

Exception:

The authentication handler registered for scheme 'OpenIdConnect' is 'OpenIdConnectHandler' which cannot be used for SignInAsync. The registered sign-in schemes are: Cookies.

Microsoft.AspNetCore.Authentication.AuthenticationService.SignInAsync(HttpContext context, string scheme, ClaimsPrincipal principal, AuthenticationProperties properties)
Microsoft.AspNetCore.Authentication.RemoteAuthenticationHandler<TOptions>.HandleRequestAsync()
Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
        var builder = WebApplication.CreateBuilder(args);
        
        // Add Entra authentication
        builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
            .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
        
        
        // Add DocuSign authentication
        builder.Services.AddAuthentication(options =>
                    {
                        options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                        options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                        options.DefaultChallengeScheme = "DocuSign";
                    })
                    .AddCookie()
                    .AddOAuth("DocuSign", options =>
                    {
                        options.ClientId = this.Configuration["DocuSign:ClientId"];
                        options.ClientSecret = this.Configuration["DocuSign:ClientSecret"];
                        options.CallbackPath = new PathString("/ds/callback");
                        options.AuthorizationEndpoint = this.Configuration["DocuSign:AuthorizationEndpoint"];
                        options.TokenEndpoint = this.Configuration["DocuSign:TokenEndpoint"];
                        options.UserInformationEndpoint = this.Configuration["DocuSign:UserInformationEndpoint"];
        
        options.Events = new OAuthEvents
        {
            OnCreatingTicket = async context =>
            {
                var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
                request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
                var response = await context.Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.HttpContext.RequestAborted);
                response.EnsureSuccessStatusCode();
                var user = JObject.Parse(await response.Content.ReadAsStringAsync());
                user.Add("access_token", context.AccessToken);
                user.Add("refresh_token", context.RefreshToken);
                user.Add("expires_in", DateTime.Now.Add(context.ExpiresIn.Value).ToString());
                using (JsonDocument payload = JsonDocument.Parse(user.ToString()))
                {
                    context.RunClaimActions(payload.RootElement);
                }
            }
    
    //lines 151 - 244 from https://github.com/docusign/code-examples-csharp/blob/master/launcher-csharp/Startup.cs
    
    // Add controllers and Razor pages with Auth
    builder.Services.AddControllersWithViews(options =>
        var policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticateUser()
            .Build();
        options.Filters.Add(new AuthorizeFilter(policy));
    }).AddMicrosoftIdentityUI();

    —-----------------
    [Authorize(AuthenticationSchemes = "DocuSign")]
    public IActionResult Login(string authType = "CodeGrant", string returnUrl = "/")
    {
        if (authType == "CodeGrant") 
        {
            return Challenge(new AuthenticationProperties() { RedirectUri = returnUrl });
        }
    }

Here is DocuSign documentation I was using as guidance: https://developers.docusign.com/docs/esign-rest-api/sdks/csharp/auth/


Solution

  • If your users are always present when they're using your "internal solution," (your "app") then the best is to set up your Docusign account to use your Entra/AD system as SSO authentication. This uses the OAuth Authorization Code flow via a browser.

    Cookies are used to create/maintain Docusign's login session.

    It may work without cookies since your app will be storing/using the Access Token. Try it.

    If your app sometimes/always is used without the user being present then you can use the OAuth JWT flow. In this case, your app checks (via Entra/AD) whether the user initiating the operation is authenticated or not (and who they are). Then your app uses this info to impersonate the user by obtaining an Access Token associated with the user via the JWT OAuth flow. (A browser is not used for JWT OAuth.)

    Some developers use the impersonation/JWT pattern even if the user is always present. Doing so adds extra maintenance/configuration complexity since your admins will need to maintain a mapping table between the authorized Entra/AD users and the matching users defined in Docusign.

    Added

    Docusign switched to using OAuth JWT impersonation about 8 years ago. Since then, you have two options with JWT impersonation. To impersonate someone you now need their Docusign UserID (which can be looked up and cached). Before OAuth, you could "act" as them by just supplying their email.

    Use a "service user account"

    For example, create a Docusign user named "HR." And your app always obtains an access token to impersonate the HR user. This means that all of the envelopes sent by your app will be sent by the HR user, will belong to that user, etc.

    The downside is that User 1 will probably be able to see the envelopes sent by User 2 since both envelopes, at the Docusign level, were sent by the same user (HR).

    Do not give admin permissions to the service user account! If your app is just going to send envelopes there is no need for the service user to be an admin.

    Impersonate individual users' accounts

    This is often the best strategy since it enables each user's envelopes sent by the app to be sent from the user's Docusign account. If the authorization (AD) system provides the user's email address, then the app can:

    1. Look up the Docusign UserID for the user from the email. (Do this as an admin user. Use JWT to create an admin's access token. Then look up the user to get the User ID.)

    2. Impersonate the user via the JWT Auth.

    The Email to User ID mapping can be cached by the app since it doesn't change. (This assumes that all users have their own Docusign user account.)