SaaS CMS has officially launched! Learn more now.

Setup Service API Authentication for Azure AD (Entra)

Vote:
 

Hello,

Although I looked in several places, I'm sure that I'm missing a simple piece of information, but at this moment I totally don't understand what am I missing.

First, I setup the commerce website without any issues and without any problems - everything is working fine. I even configured the Azure AD authentication for the administration interface according to the official available guides - everything is working.

Second, I used the Service API to retrieve content information and it's working without any issues:

https://localhost:5000/api/episerver/v3.0/content/18

Then, though I configured the commerce API to disable scope validation (like I did with the content delivery), it seems that I'm not able to call any commerce API URLs being unauthenticated:

https://localhost:5000/episerverapi/commerce/catalogs

This returns 401 Unauthorized:

{
    "message": "User is not authorized for this request."
}

Now, the problem is that I try to authenticate the user first by obtaining the token using this URL:

https://localhost:5000/api/episerver/connect/token

And here is the issue that I have and cannot fix:

System.InvalidOperationException: No service for type 'EPiServer.OpenIDConnect.Internal.OpenIDConnectSignInManager' has been registered.
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(IServiceProvider provider, Type serviceType)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService[T](IServiceProvider provider)
   at EPiServer.OpenIDConnect.Controllers.Internal.AuthorizationController..ctor()
   at lambda_method14(Closure, IServiceProvider, Object[])
   at Microsoft.AspNetCore.Mvc.Controllers.ControllerFactoryProvider.<>c__DisplayClass6_0.<CreateControllerFactory>g__CreateController|0(ControllerContext controllerContext)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()

And this is how I configured the authentication of the Service API:

        services.ConfigureContentApiOptions(options =>
        {
            options.EnablePreviewFeatures = true;
            options.IncludeEmptyContentProperties = true;
            options.FlattenPropertyModel = false;
            options.IncludeMasterLanguage = false; 
                
        });
        
        services.AddContentDeliveryApi(JwtBearerDefaults.AuthenticationScheme, options => {
                options.SiteDefinitionApiEnabled = true;
            })
            .WithFriendlyUrl()
            .WithSiteBasedCors();

        services.AddCommerceApi<ApplicationUser>(OpenIDConnectOptionsDefaults.AuthenticationScheme, o =>
        {
            o.DisableScopeValidation = true;
        });
        
        // Content Definitions API
        services.AddContentDefinitionsApi(options =>
        {
            // Accept anonymous calls
            options.DisableScopeValidation = true;
        });

        // Content Management
        services.AddContentManagementApi(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            // Accept anonymous calls
            options.DisableScopeValidation = true;
        });

        // Service API configuration
        services.AddServiceApiAuthorization(JwtBearerDefaults.AuthenticationScheme);

And this is how I configured the OpenIdConnect:

services
            .AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = "azure-cookie";
                options.DefaultChallengeScheme = "azure";
            })
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme,
                options =>
                {
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidIssuer = "https://login.microsoftonline.com/" +
                                      this.configurationProvider["Authentication:AzureTenantId"] + "/v2.0",
                        ValidateIssuer = true,
                        ValidAudience = this.configurationProvider["Authentication:AzureClientId"],
                        ValidateAudience = true,
                        ValidateLifetime = true,
                        // IssuerSigningKeys = oidcConfig.SigningKeys,
                        ValidateIssuerSigningKey = true
                    };
                })
            .AddCookie("azure-cookie", options =>
            {
                options.Events.OnSignedIn = async ctx =>
                {
                    if (ctx.Principal?.Identity is ClaimsIdentity claimsIdentity)
                    {
                        // Syncs user and roles so they are available to the CMS
                        var synchronizingUserService = ctx
                            .HttpContext
                            .RequestServices
                            .GetRequiredService<ISynchronizingUserService>();

                        await synchronizingUserService.SynchronizeAsync(claimsIdentity);
                    }
                };
            })
            .AddOpenIdConnect("azure", options =>
            {
                options.SignInScheme = "azure-cookie";
                options.SignOutScheme = "azure-cookie";
                options.ResponseType = OpenIdConnectResponseType.Code;
                options.CallbackPath = "/signin-oidc";
                options.ClientSecret = this.configurationProvider["Authentication:AzureAppSecret"];
                options.UsePkce = true;

                // If Azure AD is register for multi-tenant
                //options.Authority = "https://login.microsoftonline.com/" + "common" + "/v2.0";
                options.Authority = "https://login.microsoftonline.com/" +
                                    this.configurationProvider["Authentication:AzureTenantId"] + "/v2.0";
                options.ClientId = this.configurationProvider["Authentication:AzureClientId"];

                options.Scope.Clear();
                options.Scope.Add(OpenIdConnectScope.OpenIdProfile);
                options.Scope.Add(OpenIdConnectScope.OfflineAccess);
                options.Scope.Add(OpenIdConnectScope.Email);
                options.MapInboundClaims = false;

                options.TokenValidationParameters = new TokenValidationParameters
                {
                    RoleClaimType = "roles",
                    NameClaimType = "preferred_username",
                    ValidateIssuer = false
                };

                options.Events.OnRedirectToIdentityProvider = ctx =>
                {
                    // Prevent redirect loop
                    if (ctx.Response.StatusCode == 401)
                    {
                        ctx.HandleResponse();
                    }

                    return Task.CompletedTask;
                };

                options.Events.OnAuthenticationFailed = context =>
                {
                    context.HandleResponse();
                    context.Response.BodyWriter.WriteAsync(Encoding.ASCII.GetBytes(context.Exception.Message));
                    return Task.CompletedTask;
                };
            });

Can you please help me a bit understanding what am I missing?

Thank you very much.

#321286
Apr 30, 2024 18:37
Vote:
 

I'm back with an idea - it seems that due to the thing that I'm using the OpenIdConnect (AzureAD), I have to go the the AzureAD services in order to obtain the token:

https://login.microsoftonline.com/[tenant-id]/oauth2/v2.0/token

From there I obtained a JWT token that I use to send requests to the Service API:

https://localhost:5000/episerverapi/commerce/catalogs

At this moment the error that I'm receiving is the following:

{
    "message": "User is not authorized for this request."
}

Do you please have any ideas what is wrong?

Thank you!

#321287
Apr 30, 2024 20:26
Vote:
 

Hi Evdin

Here is the issue that I see in your code snippet above

For the ServiceApi configuration, the scope validation has not been disabled, but other API services have(e.g. Content Delviery, Definition, Management). 

For a quick test, try to disable the ServiceApi scope validation to see if it's working. 

Once it's working, to bring back the scope validation, there are two steps you need to perform

  1. Set up the scope in your IDP first
  2. Configure the new scope in AddServiceApiAuthorization in code.

Alternatively, you can skip the 2nd step if you put ServiceAPI default scope value (epi_service_api) in your IDP.

https://docs.developers.optimizely.com/customized-commerce/v1.3.0-service-api-developer-guide/docs/installation-and-configuration

I hope you find this helpful. 

#321325
May 01, 2024 1:46
Vote:
 

Hello and thank you for your response.

First, regarding disabling the scope validation - I did it but no effect - it still requires the token in the authorization header.

Second, I think that I start to understand how it works:

a) services.AddOpenIDConnect<SiteUser>() -> this is being used for the OpenID client authentication - meaning that this will help setting up the possibility of registering applications that can connect to the service API.

b) services.AddOpenIdConnect() -> this is being used for the authentication for the administration interface - this is a total different story.

Now, if I use the application registration for OpenIDConnect (according to the documentation):

                var application = new OpenIDConnectApplication()
                {
                    ClientId = "postman-client",
                    ClientSecret = "postman",
                    Scopes =
                    {
                        ContentDeliveryApiOptionsDefaults.Scope,
                        ContentManagementApiOptionsDefaults.Scope,
                        ContentDefinitionsApiOptionsDefaults.Scope,
                        ServiceApiOptionsDefaults.Scope
                    }
                };                  
                
                application.RedirectUris.Add(new Uri("https://oauth.pstmn.io/v1/callback"));
                
                configureOptions.Applications.Add(application);     

I can issue a token that I can use for commerce service API.

At this moment the biggest issue that I have is that I don't know if this is correct or not (mixing the OpenIDConnect from AzureAD with OpenIDConnect for service API).

Thank you,

Evdin

#321396
May 02, 2024 12:27
Vote:
 

Hi Evdin

My bad. I decompiled to the service API and scope validation is disabled by default. So what I said in my last response is not applicable.

How do you obtain the token after you add services.AddOpenIDConnect<SiteUser>()? If you obtain the token from "api/episerver/connect/token" , I think you app is not using Entra for protecting your service API, instead it uses asp.net identity. 

The mix usage  is not a problem,  what's your security team required  matters.

#321508
May 04, 2024 11:01
Vote:
 

Hello,

For now, the only option that I saw for protecting the service API is to use their own OpenIDConnect services - unfortunately I couldn't find any references or documentation on how to use the Entra authentication also for the service API not only for the admin interface. Do you have any info on how this can be done?

Thank you,

Evdin

#321645
May 07, 2024 6:44
Vote:
 

Hi

I think you're getting close before you swith to use Episerver OpenIdConnect package. The way i see how it should work with Entra or other SSO is 

  1. Create a new role (e.g. ServiceApiManager) in your Idp (in Entra, you create App role)
  2. Associate your API user with this role
  3. Grant this role permission to Episerver Service API in CMS (read and write, check out docs for more details) 
#321700
May 08, 2024 1:40
Vote:
 

Hello and thank you for your response.

So, I think I'm going crazy about this issue because after spending a lot of hours, I got into a point where I don't have too much information.

Let's start with the beginning.

First, create a new role in Entra - I created an appRole as you suggested (WebApiUsers) and I gave it access to the application in API permissions.

Then, I declared the new role in the appSettings file like this:

"EPiServer": {
    "Cms": {
      "MappedRoles": {
        "Items": {
          "CmsEditors": {
            "MappedRoles": [ "WebEditors", "WebAdmins" ]
          },
          "CmsAdmins": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "CommerceAdmins": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "CatalogManagers": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "CustomerServiceRepresentatives": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "MarketingManagers": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "OrderManagers": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "ReportManagers": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "ApiUsers": {
            "MappedRoles": [ "WebApiUsers" ]
          }
        }
      }
    },

After that, I went to the functions permisions and I gave to this role the rights to read and write using the service API.

Now, the hardest part came from the configuration of the Azure AD token validator:

            services.AddMicrosoftIdentityWebApi(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters()
                {
                    ValidateIssuer = true,
                    ValidIssuer = "https://login.microsoftonline.com/" +
                                  this.configurationProvider["Authentication:TenantId"] + "/v2.0",
                    ValidAudience = this.configurationProvider["Authentication:ClientId"],
                    ValidateAudience = true,
                    ValidateLifetime = true
                };
                
                options.Events = new JwtBearerEvents()
                {
                    OnAuthenticationFailed = context =>
                    {
                        return Task.CompletedTask;
                    },
                    OnChallenge = context =>
                    {
                        return Task.CompletedTask;
                    },
                    OnForbidden = context =>
                    {
                        return Task.CompletedTask;
                    },
                    OnMessageReceived = context =>
                    {
                        return Task.CompletedTask;
                    },
                    OnTokenValidated = context =>
                    {
                        return Task.CompletedTask;
                    }
                };
            }, options =>
            {
                this.configurationProvider.Bind("Authentication", options);
            }, 
                jwtBearerScheme: "OpenIddict.Validation.AspNetCore")
            .EnableTokenAcquisitionToCallDownstreamApi(options =>
                {
                    this.configurationProvider.Bind("Authentication", options);
                }
            ).AddInMemoryTokenCaches();

The token is validated, the user has that role (checked the OnTokenValidated event) but the answer is:

{
    "message": "User is not authorized for this request."
}

At this moment I don't understand anything anymore; what drives me crazy is that the documentation (https://docs.developers.optimizely.com/content-management-system/v1.5.0-content-delivery-api/docs/api-authentication) clearly states this:

Using the following methods, you should secure requests to the APIs with OpenID Connect and Bearer Tokens (JWT).

    Configure the application to use an external login provider and enable the JWT Bearer token middleware.
    Use the implementation based on OpenIddict, ASP.NET Identity, and Entity Framework. The Optimizely implementation gives you the basic OpenID Connect support and the following grant types or flows:
        Authorization code – For interactive clients.
        Client Credentials – For machine-to-machine communication.
        Resource Owner Password – This flow is turned off by default, and you should only use this flow for backward compatibility. This flow is less secure.

So this means that the only way to go forward is only through the `EPiServer.OpenIDConnect` package?

I really think that I'm missing something big time or I'm doing a big mistake somewhere.

Thank you,

Evdin

#321703
May 08, 2024 9:29
Vote:
 

This should work, you need to setup azure ad authenitcation then you when you register service api AddServiceApiAuthorization("Schema").  There was a bug that it required open id connect but that was fixed to allow any authenication schema to handle auth for service api.

#321824
May 10, 2024 14:05
Vote:
 

Hello,

I've simplifed everything as much as I could. I still think that I'm doing something wrong but still don't know what.

This is a small recap, if you can follow me, it would be great - thank you in advance!

1) The initialization code for the webapi

        services
            .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddMicrosoftIdentityWebApi(
                configureJwtBearerOptions: options =>
                {
                    
                },
                configureMicrosoftIdentityOptions: options =>
                {
                    this.configurationProvider.Bind("Authentication", options);
                },
                jwtBearerScheme: JwtBearerDefaults.AuthenticationScheme,
                subscribeToJwtBearerMiddlewareDiagnosticsEvents: true)
            .EnableTokenAcquisitionToCallDownstreamApi(options =>
            {
                this.configurationProvider.Bind("Authentication", options);
            })
            .AddInMemoryTokenCaches();

2) The initialization code the the admin authentication:

       services
            .AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
            .AddMicrosoftIdentityWebApp(options =>
            {
                this.configurationProvider.Bind("Authentication", options);
            })
            .EnableTokenAcquisitionToCallDownstreamApi();

3) The initialization code for the service API settings:

        services.ConfigureContentApiOptions(options =>
        {
            options.EnablePreviewFeatures = true;
            options.IncludeEmptyContentProperties = true;
            options.FlattenPropertyModel = false;
            options.IncludeMasterLanguage = false; 
                
        });

        services.AddContentDeliveryApi(
                JwtBearerDefaults.AuthenticationScheme, options =>
                {
                    options.SiteDefinitionApiEnabled = true;
                })
            .WithFriendlyUrl()
            .WithSiteBasedCors();

        services.AddCommerceApi<SiteUser>(JwtBearerDefaults.AuthenticationScheme, o =>
        {
            o.DisableScopeValidation = true;
        });
        
        // Content Definitions API
        services.AddContentDefinitionsApi(options =>
        {
            options.DisableScopeValidation = true;
        });

        // Content Management
        services.AddContentManagementApi(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            options.DisableScopeValidation = true;
        });

        // Service API configuration
        services.AddServiceApiAuthorization(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            options.DisableScopeValidation = true;
        });

(as you can see, I totally removed the OpenIdConnect from EpiServer - just left the Azure AD one using the new libraries)

4) The app role / user group in the appsettings:

"EPiServer": {
    "Cms": {
      "MappedRoles": {
        "Items": {
          "CmsEditors": {
            "MappedRoles": [ "WebEditors", "WebAdmins" ]
          },
          "CmsAdmins": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "CommerceAdmins": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "CatalogManagers": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "CustomerServiceRepresentatives": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "MarketingManagers": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "OrderManagers": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "ReportManagers": {
            "MappedRoles": [ "WebAdmins" ]
          },
          "ApiUsers": {
            "MappedRoles": [ "WebApiUsers" ]
          }
        }
      }
    }

(is the last one in the list - ApiUsers and the role from the application is WebApiUsers.

5) I set the "ApiUsers" role in the admin functions to have both read and write rights (I also checked the database table and the records are there).

6) I went to debug mode and I took the JWT bearer events and I hooked to OnTokenValidated and in there I checked the current principal if it's authenticated and also if it's part of the group "WebApiUsers" - guess what?! both are true!

What am I missing? :-(

Thank you for your help!

#321958
May 13, 2024 10:50
Vote:
 

OK, I have a very good news, though I don't understand why?!

If I change the virtual role declaration in the appsettings.json file to

"WebApiUsers": {
            "MappedRoles": [ "WebApiUsers" ]
          }

It's working.

So, the name of the virtual role should be the same of the Azure AD role - though I don't understand why?!

Thanks,

Evdin

#321961
May 13, 2024 11:44
Vote:
 

Hello everyone,

So, I made it working - the documentation is lacking so many information that can save a lot of time from many developers - I really don't think that I'm the first one that had these issues.

Let's take it step by step.

First, on my last post I was able to have it working only if the virtual role name was the same as the name of the real application role defined in Azure AD - found out why - because there should be a piece of code that saves the received identity (exactly like it's happening with the human users on the Azure AD cookie auth):

       services
            .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddMicrosoftIdentityWebApi(
                configureJwtBearerOptions: options =>
                {
                    #region JWT bearer events
                    
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
                        NameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"
                    };

                    options.Events = new JwtBearerEvents()
                    {
                        OnTokenValidated = async context =>
                        {
                            if (context.Principal?.Identity is ClaimsIdentity claimsIdentity)
                            {
                                // Syncs user and roles so they are available to the CMS
                                ISynchronizingUserService synchronizingUserService = context
                                    .HttpContext
                                    .RequestServices
                                    .GetRequiredService<ISynchronizingUserService>();

                                await synchronizingUserService.SynchronizeAsync(claimsIdentity);
                            }
                        }
                    };

                    #endregion
                },
                configureMicrosoftIdentityOptions: options =>
                {
                    this.configurationProvider.Bind("Authentication", options);
                },
                jwtBearerScheme: JwtBearerDefaults.AuthenticationScheme,
                subscribeToJwtBearerMiddlewareDiagnosticsEvents: true)
            .EnableTokenAcquisitionToCallDownstreamApi(options =>
            {
                this.configurationProvider.Bind("Authentication", options);
            })
            .AddInMemoryTokenCaches();

Here is very important to note two things:

a) you need to specify which is the role claim type and which is the name claim type (otherwise you will get some nasty NullReferenceException regarding the Name property).

b) the saving of the app identity is made in the `OnTokenValidated` event.

Then another issue that I had was about setting `DisableScopeValidation` on false - though the app roles were there (the actual scopes for each functionality) I was still getting errors about Insufficient scopes. In order to fix this, you need to set the Claim type from where the scopes should be read:

        services.ConfigureContentApiOptions(options =>
        {
            options.EnablePreviewFeatures = true;
            options.IncludeEmptyContentProperties = true;
            options.FlattenPropertyModel = false;
            options.IncludeMasterLanguage = false; 
                
        });

        services
            .AddContentDeliveryApi(JwtBearerDefaults.AuthenticationScheme,
                options =>
                {
                    options.SiteDefinitionApiEnabled = true;
                })
            .WithFriendlyUrl()
            .WithSiteBasedCors();

        services.AddCommerceApi<SiteUser>(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            options.ScopeClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
            options.DisableScopeValidation = false;
        });
        
        // Content Definitions API
        services.AddContentDefinitionsApi(options =>
        {
            options.ScopeClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
            options.DisableScopeValidation = false;
        });

        // Content Management
        services.AddContentManagementApi(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            options.ScopeClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
            options.DisableScopeValidation = true;
        });

        // Service API configuration
        services.AddServiceApiAuthorization(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            options.ScopeClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
            options.DisableScopeValidation = false;
        });

        services.AddCors(options =>
        {
            options.AddPolicy(name: "Standard Policy", builder =>
            {
                builder
                    .AllowAnyHeader()
                    .AllowAnyOrigin()
                    .AllowAnyMethod();
            });
        });

        services.ConfigureContentDeliveryApiSerializer(settings =>
            settings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore);

I really hope that sometime someone will be have some hours or days saved by this post. :-)

Evdin

#322011
May 14, 2024 7:13
* You are NOT allowed to include any hyperlinks in the post because your account hasn't associated to your company. User profile should be updated.