I'm working with a multi-tenant application where we have an API project and another WEB project (razor pages). In this context, each customer (tenant) has a specific database and there is a third project, called Manager which is responsible for storing the information of each tenant. Thus, whenever a client accesses one of the projects (WEB or API) we identify the database and the parameters needed by the tenant based on their domain. For example:
client1-api.mydomain.com
client1-web.mydomain.com
client2-api.mydomain.com
client2-web.mydomain.com
Because the WEB and API projects are unique and have multiple databases (one for each tenant), Identity Server 4 was configured in the WEB project, so that whenever a client needs to connect, it logs in via the WEB project or generate a JWT token via the address {clientname}-web.mydomain.com/connect/token (for example: client1-web.mydomain.com/connect/token).
Thus, the API has an Identity Server 4 authentication and authorization configuration, so that the token generated in the WEB project can be used to access its resources.
However, we are not able to make the Authority parameter dynamically update on a per-customer basis (the tenant). The initial idea was to build a middlaware that checks the connected tenant and loads, through the Manager, the Authority parameter and set it whenever a request is made. In general, middleware works and the Authority parameter is set. However, whenever a client tries to access an API resource, the token is invalid and therefore the request is not authorized. Analyzing this problem, I verified that whenever the API receives a request, it tries to validate the token in the WEB project, but when analyzing the address of the Tenant that is making the request to the WEB project, I noticed that it is the same that is set by default in the method ConfigureServices and with that, authentication and authorization only works for this default client.
So is there any way to dynamically set the Identity Server 4 Authority in an application with this scenario?
API Startup:
public void ConfigureServices(IServiceCollection services)
{
//other settings
services.AddAuthentication(option =>
{
option.DefaultAuthenticateScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
option.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddIdentityServerAuthentication(options =>
{
options.Authority = "https://client1-web.mydomain.com";
});
services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "any1", "any2");
});
});
//other settings
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
//other settings
app.UseAuthentication();
app.UseAuthorization();
app.UseTenantConfig();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers()
.RequireAuthorization("ApiScope");
});
//other settings
}
TENANT PARAMETER CONFIGURATION MIDDLAWARE
public async Task Invoke(HttpContext context,
ITenantParameterService tenantParameterService,
IOptions<IdentityServerAuthenticationOptions> identityServerAuthenticationOptions)
{
var tenantInfo = context.RequestServices.GetRequiredService<TenantInfo>(); //Get tenant domain
var connectionString = tenantParameterService.GetConnectionStringByTenant(tenantInfo.Address);
var authorityParameter = tenantParameterService.GetParameterByTenant(tenantInfo.Address, "Authority");
var myContext = context.RequestServices.GetService<MyDbContext>();
myContext?.Database.SetConnectionString(connectionString);
identityServerAuthenticationOptions.Value.Authority = authorityParameter;
await _next(context);
}
By executing the code, whenever a request is made in the application, middlaware will update the necessary parameters, including the connection string. However the problem is: if the token is generated in client1-web.mydomain.com to be used in client1-api.mydomain.com the authentication works perfectly due to the default configuration at startup (we use this configuration due to the boostrap of the application when it starts and even leaving null for it to be set dynamically, the authentication problem persists). Now, if the token is generated in client2-web.mydomain.com to be used in client2-api.mydomain.com I get the message saying that the token is invalid: Bearer error="invalid_token", error_description="The issuer 'https://client2-web.mydomain.com' is invalid". When decoding the token generated in this case, it was possible to notice that it is correct, because the iss is like https://client2-web.mydomain.com and therefore, the error is in the token and yes in its validation by the API.
Related
Odd behavior in VS Code using Kestrel. In the Claims transformer I can get the identity.Name as the logged in user...me. But in the Role Handler the claims come up null and no user name. I would think if Windows authentication weren't working then it wouldn't work at all. Works in ASP.NET Core 5 with Visual Studio 2019 but not in ASP.NET Core 6 in VS Code. Yes, I understand that Visual Studio is using IIS Express and VS Code is using Kestrel.
UPDATE: I have looked at the request in Fiddler and it seems like the second request is actually being authenticated, I see a valid Authorization header in the request with Negotiate. So that would seem to indicate that the Windows auth part of this is working. I then see a 400 error which means that the request is badly formed. I'll have to take a look at that.
Further update. The real issue was authentication. I created a service principal name and got it working.
The explicit message in Fiddler is:
Authorization Header (Negotiate) appears to contain a Kerberos ticket:
services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
ClaimsIdentity identity = principal.Identity as ClaimsIdentity;
string userName = identity.Name.ToLower();
bool auth = identity.IsAuthenticated;
string[] roles = new string[]{"admin"};
foreach (string role in roles)
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
return Task.FromResult(principal);
}
public class AuthenticatedRoleHandler : AuthorizationHandler<AuthenticatedRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,AuthenticatedRequirement requirement)
{
ClaimsPrincipal user = context.User;
if (user != null && user.Identity.IsAuthenticated) {
context.Succeed(requirement);
} else {
context.Fail();
}
return Task.CompletedTask;
}
}
I'm adding the Program.cs code.
string CorsPolicy = "CorsPolicy";
WebApplicationBuilder? builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel();
ConfigurationManager _configuration = builder.Configuration;
// Add services to the container.
IServiceCollection? services = builder.Services;
services.AddTransient<IActiveDirectoryUserService,
ActiveDirectoryUserService>();
services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
services.AddControllersWithViews();
services.AddScoped<IClaimsTransformation, MyClaimsTransformer>();//only
runs if authenticated, not being authenticated here
services.AddAuthorization(options =>
{
options.FallbackPolicy = options.DefaultPolicy;
options.AddPolicy("AuthenticatedOnly", policy => {
policy.Requirements.Add(new AuthenticatedRequirement(true));
});
});
services.AddSingleton<IAuthorizationHandler, AppUserRoleHandler>();
services.AddSingleton<IAuthorizationHandler, AuthenticatedRoleHandler>
();
services.AddCors(options =>
{
options.AddPolicy(CorsPolicy,
builder => builder
.WithOrigins("https://localhost:7021","https://localhost:44414") //Note: The URL must be specified without a trailing slash (/).
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
WebApplication app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseAuthentication();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
According to my test, it works well when I am using the Kestrel(.net 6) inside the VS(its same as run the application inside the Kestrel like VS code). The only difference between the windows authentication for the IIS express and the Kestrel is if you enable the windows authentication for the IIS express, all the request which come to the application will firstly need the authentication which means all the request come to the AuthenticatedRoleHandler has already been authenticated.
But if you use services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();`, the request will not firstly be authenticated, if the application find it doesn't contain any authentication relatede information, then it will return response to let the client put in the windows credential. This makes the AuthenticatedRoleHandler fired twice. The first one is authentication user name is null, but after the user typed in the credential, we could get the user name inside it.
My test result like below:
If this still not work at your side, please update the whole codes which related with how you register the requirement and how you use it.
My application is a hybrid approach where use ASP.NET Core MVC as my backend. I have various controllers which my front end uses to pull data from our database and also to do API calls on MS Graph. I am using the following program.cs file to get the authentication initiated when a user first logs on to the site:
//authentication pipline
builder.Services.AddHttpContextAccessor();
var initialScopes = builder.Configuration.GetValue<string>("DownstreamApi:Scopes")?.Split(' ');
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(options =>
{
builder.Configuration.Bind("AzureAd", options);
options.Events = new OpenIdConnectEvents
{
//Tap into this event to add a UserID Claim to a new HttpContext identity
OnTokenValidated = context =>
{
//This query returns the UserID from the DB by sending the email address in the claim from Azure AD
string query = "select dbo.A2F_0013_ReturnUserIDForEmail(#Email) as UserID";
string connectionString = builder.Configuration.GetValue<string>("ConnectionStrings:DBContext");
string signInEmailAddress = context.Principal.FindFirstValue("preferred_username");
using (var connection = new SqlConnection(connectionString))
{
var queryResult = connection.QueryFirst(query, new { Email = signInEmailAddress });
var claims = new List<Claim>
{
new Claim("UserID", queryResult.UserID.ToString())
};
var appIdentity = new ClaimsIdentity(claims);
context.Principal.AddIdentity(appIdentity);
}
return Task.CompletedTask;
},
};
}).EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(builder.Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();
//Add Transient Services
builder.Services.AddTransient<IOneDrive, OneDrive>();
builder.Services.AddControllers(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();
builder.Services.AddRazorPages().AddRazorPagesOptions(options =>
{
options.Conventions.AllowAnonymousToFolder("/Login");
options.Conventions.AuthorizeFolder("/");
options.Conventions.AuthorizeFolder("/files");
}).AddMicrosoftIdentityUI();
// Add the UI support to handle claims challenges
builder.Services.AddServerSideBlazor()
.AddMicrosoftIdentityConsentHandler();
builder.Services.AddRequiredScopeAuthorization();
In the Azure AD portal my application is registered as a web app. So when a user initially goes to the site they are redirected to https://login.microsoftonline.com/blahblah to get the login process started. This is automated by the Azure AD identity platform. Then once the login occurs they are redirected to localhost where the VueJS spa is loaded (localhost:43862). My spa uses various axios requests to the controllers and they pull data and vue router loads components. However, my issue is say the user needs to relog in because the cookie is expired or they logged out in another tab. The next axios request made by the expired session does not redirect the user to Azure login screen but instead results in an CORS error. So I need to get my axios requests to force the page redirect to Azure AD login screen (which probably is the worst idea since CORS policy is resulting in error) or have it return a redirect to localhost/login which is my own custom login screen with a button to Azure AD login and shouldnt impact CORS. So how do I intercept this Azure AD redirect to Azure AD login and replace with my own?
I have also tried to return a 401 error code so I could check for that in my axios request but to no avail it does nothing. If I put a breakpoint there it does hit this code but it does not change the status code of the response and I still get 302. My code for that was to try and add to the event :
OnRedirectToIdentityProvider = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
}
My other ideas was maybe I should set my CORS policy to allow redirects from login.microsoft.com? Or would this be bad practice?
I can answer part of your question... First, for our API application which is protected by Azure AD, what the API should do is validating the request whether it contained a correct access token in the request header, if yes, give the response, if no, then give error like 401 or 403. A normal API application shouldn't have a UI to let users sign in. Anyway, if you want to expose an API in an MVC project, it's OK, but for API itself, it shouldn't have a UI.
Let's see sample below, I had a .net 6 web api project, and here's my program.cs:
using Microsoft.Identity.Web;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration);
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
And it requires configurations in appsetting.json.
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"ClientId": "azure_ad_client_id",
"ClientSecret": "client_secret",
"Domain": "tenant_id",
"TenantId": "tenant_id",
//"Audience": "api://azure_ad_client_id_which exposed_api" // here I used the same azure ad app to expose API, so I can comment this property
},
And this is the Controller:
[ApiController]
[Route("[controller]")]
[Authorize]
public class WeatherForecastController : ControllerBase
{
[RequiredScope("Tiny.Read")]
[HttpGet]
public string Get()
{
return "world";
}
}
I had an Azure AD app, and I exposed an API like this:
I also add this API for the same Azure AD app.
Then let's do a test. When I call this API directly, I will get 401 error:
If I used an expired token within the request, I will also get 401 error:
But if I used a correct token(go to https://jwt.io to decode the token, we should see it containing correct scope, for me its "scp": "Tiny.Read",), I will get response:
And till now, the API part had finished. Let's see the client SPA. For SPA, you should integrate MSAL so that you can make your users to sign in via Azure AD, and generate the access token for calling MS graph API or your own API. The code for generating access token should be the same but you should set different scope for different API. In my scenario, my API required a scope Tiny.Read, then I should set in my client App.
Here's an screenshot for generating access token in react. You need to set the scope in your code.
Now you have the method to generate access token, you already know the API url. Then you can send request to call api, using AJAX, using fetch, or something else, sending an http request is ok. And in the calling api part, you also need to handle the response. If the response code is 401, then you need to do some logic, maybe redirect to the sign in page. And you said you had trouble here, you met CORS issue. I can't answer this part. I think it depends on how you redirect to Azure AD sign in page. I'm afraid you can take a look at this sample to learn how to sign in users and call graph api.
Bottom line up front... when I try to connect Power Query from Excel to my app, I get the error described here - We were unable to connect because this credential type isn’t supported for this resource. Please choose another credential type.
Details...
My app was created from the ASP.NET Core Web App MVC template.
It uses the Microsoft Identity Platform for authentication.
// Sign-in users with the Microsoft identity platform
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(options =>
{
builder.Configuration.Bind("AzureAd", options);
options.Events = new OpenIdConnectEvents();
options.Events.OnTokenValidated = async context =>
{
//Calls method to process groups overage claim.
var overageGroupClaims = await GraphHelper.GetSignedInUsersGroups(context);
};
})
.EnableTokenAcquisitionToCallDownstreamApi(options => builder.Configuration.Bind("AzureAd", options), initialScopes)
.AddInMemoryTokenCaches();
It uses Azure AD group claims for authorization.
// Adding authorization policies that enforce authorization using group values.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Administrators", policy => policy.Requirements.Add(new GroupPolicyRequirement(builder.Configuration["Groups:Administrators"])));
options.AddPolicy("SuperUsers", policy => policy.Requirements.Add(new GroupPolicyRequirement(builder.Configuration["Groups:SuperUsers"])));
options.AddPolicy("Deliverables", policy => policy.Requirements.Add(new GroupPolicyRequirement(builder.Configuration["Groups:Deliverables"])));
options.AddPolicy("Orders", policy => policy.Requirements.Add(new GroupPolicyRequirement(builder.Configuration["Groups:Orders"])));
options.AddPolicy("Users", policy => policy.Requirements.Add(new GroupPolicyRequirement(builder.Configuration["Groups:Users"])));
});
builder.Services.AddSingleton<IAuthorizationHandler, GroupPolicyHandler>();
In addition to the standard MVC UI, it also has an OData controller for API access.
[ApiController]
[Route("[controller]")]
//[Authorize(Policy = "Users")]
[AllowAnonymous]
public class OdataController : Controller Base
From a browser, everything works perfectly. Access is properly controlled by [Authorize(Policy = "Users")].
From Excel, however, it only works if I set the controller to [AllowAnonymous] and use the Get Data > From Web option.
If I try Get Data > From OData Feed, I get an error The given URL neither points to an OData service or a feed. Which is OK as long as Get Data > From Web works.
If I add the authorization policy, I get an error We were unable to connect because this credential type isn’t supported for this resource. Please choose another credential type.
Following the supported workflow here, Power Query is expecting a 401 response with a WWW_Authentication header containing the Azure AD login URL. Instead, it's being sent directly to the Azure login, and therefore the authentication fails. I did try adding the Power Query client IDs to my Azure AD app, but that had no effect.
I have done every search I can think of and am out of ideas. Can anyone help? Thanks!
I'm trying to create an MS Powerapp Custom Connector to access an ASP.NET core WebAPI, which is protected by Azure AD using the Microsoft.Identity.Web package. But no matter how I try to set things up, I'm always getting a 401 when trying to access the API via the connector.
So my setup looks like this:
Azure App registrations
I have registered two apps in Azure App Registrations. One for the API and one for the Powerapp Connector.
For the API registration I only configured settings under 'Expose an API'. I set an Application ID URI
api://f9****ca-****-****-****-3d30e9e*****
and added a scope
api://f9****ca-****-****-****-3d30e9e*****/Employees.Read.All
I also added an authorized client application using the ID of the App registration for the Powerapp (config below).
The app registration for the Powerapp is configured as follows:
For authentication the Web platform is added with redirect URI of https://global.consent.azure-apim.net/redirect.
Under 'Certificates & secrets' I added a client secret.
Under 'Api permissions' I added a permission for the scope of the API api://f9****ca-****-****-****-3d30e9e*****/Employees.Read.All as Delegated permission.
Powerapp Custom Connector config
The security tab of my custom connector is configured as follows:
Setting
Value
Auth type
OAuth 2.0
Identity Provider
Azure Active Directory
Client id
<Client ID of App reg for Powerapp>
Client Secret
<Client secret of App reg for Powerapp>
Login URL
https://login.windows.net
Tenant ID
<AAD Tenant ID>
Resource URL
api://f9****ca-****-****-****-3d30e9e***** <Application ID URI of App reg for API>
Scope
api://f9****ca-****-****-****-3d30e9e*****/Employees.Read.All
ASP.net core (3.1) Web App
I'm using the Microsoft.Identity.Web nuget package.
In my app config I added the following:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"ClientId": "<Client ID of app reg for API>",
"TenantId": "<Tenant ID>"
}
The startup class looks like this:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMicrosoftIdentityWebApiAuthentication(Configuration);
services.AddControllers();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
My controller is just decorated with the [Authorize] attribute.
Now, when I try to test an operation on the custom connector the WebAPI allways returns a 401 Unauthorized and I can't for the life of me figure out why.
I get see, that the request header has a Bearer token for the user I'm using to create the connection.
So a token is issued, but somehow the WebApi deems it invalid.
Interestingly the audience in that token is https://apihub.azure.com. Shouldn't it be the client id of the app reg? Anyway, even if I add that audience to the valid audiences in my WebAPI, I still get the 401...
Is there anything I am missing here? Any help would be really appreciated.
You forgot to add the Authenction middleware.
app.UseAuthentication();
app.UseAuthorization();
I implemented a token server using Identity Server 4.
I added a custom API endpoint to the token server and struggle with the authentication. The custom endpoint is inherited from ControllerBase and has 3 methods (GET, POST, DELETE).
I intend to call the custom endpoint from within another API using a dedicated client with credentials (server to server) implemented as HttpClient in .NET Core. There is no user involved into this.
For getting the access token I use the IdentityModel DiscoveryClient and TokenEndpoint.
So in sum I did the following so far:
setup "regular" identity server and validate it works -> it works
implement custom endpoint and test it without authorizatio -> it works
add another api resource ("api.auth") with a custom scope "api.auth.endpoint1"
setup a client with client credentials allowing access to scope "api.auth.endpoint1".
implement the HttpClient and test setup -> I get an access token via the Identity Model Token Endpoint.
Now, when I call the endpoint using the HttpClient with the access token I received I get response code 200 (OK) but the content is the login page of the identity server.
The documentation of Identity Server 4 state the use of
services.AddAuthentication()
.AddIdentityServerAuthentication("token", isAuth =>
{
isAuth.Authority = "base_address_of_identityserver";
isAuth.ApiName = "name_of_api";
});
as well as the use of
[Authorize(AuthenticationSchemes = "token")]
Unfortunatly the compiler state that .AddIdentityServerAuthentication can't be found. Do I miss a special nuget?
The nugets I use on the token server so far are:
IdentityServer4 (v2.2.0)
IdentityServer4.AspNetIdentity (v2.1.0)
IdentityServer4.EntityFramework (v2.1.1)
Figured out that part. The missing nuget for AddIdentityServerAuthentication is:
IdentityServer4.AccessTokenValidation
Struggling with the authorization based on the custom scope.
Does anyone know how the security has to be configured?
Configure a client with ClientGrantTypes = client_credentials and your api like this:
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddIdentityServerAuthentication(options =>
{
options.Authority = "http://localhost:5000";
options.ApiName = "api.auth";
});
Where ApiName is the name of the resource. Please note that resource != scope. In most samples the resource name is equal to the scope name. But not in your case, where resource name is api.auth and scope name is api.auth.endpoint1.
Configure the client to request the scope.
var tokenClient = new TokenClient(disco.TokenEndpoint, clientId, secret);
var tokenResponse = await tokenClient.RequestClientCredentialsAsync("api.auth.endpoint1");
IdentityServer will lookup the Resource name and add that to the token as audience (aud) while the scope is added as claim with type scope.
This should be enough to make it work. Also check the sample project.
Custom authentication scheme and scope based policies for different access rights bundled together looks like that:
// Startup.ConfigureServices
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddIdentityServerAuthentication("CustomAuthEndpointsAuthenticationScheme", options =>
{
options.Authority = "http://localhost:5000";
options.ApiName = "api.auth"; //IdentityServer4.Models.ApiResource.Name aka Audience
});
services.AddAuthorization(options =>
{
options.AddPolicy("Endpoint1Policy", policy => {
policy.AddAuthenticationSchemes(new string[] { "CustomAuthEndpointsAuthenticationScheme" });
policy.RequireScope("api.auth.endpoint1"); } ); //IdentityServer4.Models.Scope.Name
options.AddPolicy("Endpoint2Policy", policy => {
policy.AddAuthenticationSchemes(new string[] { "CustomAuthEndpointsAuthenticationScheme" });
policy.RequireScope("api.auth.endpoint2"); } ); //IdentityServer4.Models.Scope.Name
} );
// securing the custom endpoint controllers with different access rights
[Authorize(AuthenticationSchemes = "CustomAuthEndpointsAuthenticationScheme", Policy = "Endpoint1Policy")]
It seems not to interfere with the IdentityServer4 default endpoints nor with the ASP.NET Core Identity part.