Azure Bot project with client and server in same web app - c#

I've made an Azure bot application using the BotFramework v4 and used the WebChat control as an interface. I noticed that the bot server's dotnetcore app had a wwwroot folder with a placeholder HTML page in it, so thought it might be expedient to host the webchat client there. But now seems counter-intuitive that my webchat client is using DirectLine to send activities back to the same back-end that served it.
I had chosen the webchat client because I need to customise the appearance of the client. I also need the MVC app that serves the bot client to include Azure Active Directory B2C authentication (which it does). Users should see the webchat client before and after authentication but the bot back-end (handling the activities) needs to know whether the user is logged in and modify its behaviour accordingly (and I am struggling to achieve that part with DirectLine).
So my first question (ever on StackOverflow) is: With the Bot back-end and the webchat client front-end being hosted in the same, single Azure web app, is it necessary to use DirectLine, or is there a simpler way of doing this?
Relevant code in my Startup.cs:
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
app.UseStaticFiles(); // to allow serving up the JS, CSS, etc., files.
app.UseBotFramework(); // to add middleware to route webchat activity to the bot back-end code
app.UseSession(); // to enable session state
app.UseAuthentication(); // to enable authentication (in this case AAD B2C)
app.UseMvcWithDefaultRoute(); // to add MVC middleware with default route
}
Also in Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
// standard code to add HttpContextAssessor, BotServices, BotConfigs and memory storage singletons ommitted for brevity ...
services.AddAuthentication(sharedOptions =>
{
sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddAzureAdB2C(options => Configuration.Bind("Authentication:AzureAdB2C", options))
.AddCookie();
services.AddMvc();
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromHours(1);
options.CookieHttpOnly = true;
});
// Create and add conversation state.
var conversationState = new ConversationState(dataStore);
services.AddSingleton(conversationState);
var userState = new UserState(dataStore);
services.AddSingleton(userState);
services.AddBot<MyBot>(options =>
{
options.CredentialProvider = new SimpleCredentialProvider(endpointService.AppId, endpointService.AppPassword);
options.ChannelProvider = new ConfigurationChannelProvider(Configuration);
// Catches any errors that occur during a conversation turn and logs them to currently
// configured ILogger.
ILogger logger = _loggerFactory.CreateLogger<RucheBot>();
options.OnTurnError = async (context, exception) =>
{
logger.LogError($"Exception caught : {exception}");
await context.SendActivityAsync("Sorry, it looks like something went wrong.");
};
});
}
My controller's Index method:
public async Task<ActionResult> Index()
{
string userId;
if (User.Identity.IsAuthenticated)
{
string aadb2cUserId = User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier").Value;
Users.EnsureAccountExists(aadb2cUserId); // ensure account with given AAD identifier is know locally (by creating it if not)
userId = $"ia_{aadb2cUserId}";
}
else
{
userId = $"na_{Guid.NewGuid()}";
}
HttpClient client = new HttpClient();
string directLineUrl = $"https://directline.botframework.com/v3/directline/tokens/generate";
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, directLineUrl);
// TODO: put this in the config somewhere
var secret = "<the secret code from my bot's DirectLine channel config in the Azure portal>";
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secret);
string jsonUser = JsonConvert.SerializeObject(new { User = new { Id = userId } });
request.Content = new StringContent(jsonUser, Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
string token = string.Empty;
if (response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
token = JsonConvert.DeserializeObject<DirectLineToken>(body).token;
}
var config = new ChatConfig()
{
Token = token,
UserId = userId,
};
return View(config);
}
And finally the code in the associated view:
<div id="webchat"></div>
<script type="text/javascript">
...
/// Called asynchronously during the page load
function renderWebChat( withSound )
{
var webchatOptions =
{
directLine: window.WebChat.createDirectLine( { secret: '#Model.Token'} ),
userID: '#Model.UserId'
};
if ( withSound )
{
webchatOptions.webSpeechPonyfillFactory = window.WebChat.createBrowserWebSpeechPonyfillFactory();
}
window.WebChat.renderWebChat( webchatOptions, document.getElementById( 'webchat' ) );
document.querySelector( '#webchat > *' ).focus();
}
</script>

I'm going to disagree with Nicolas R a little bit. When it comes to directly accessing your bot, you might like to have a look at this: https://www.npmjs.com/package/offline-directline
There's also the option of hosting a bot in the browser, which I think may facilitate the sort of direct communication you're looking for.

Long question, but the answer will be a lot shorter!
So my first question (ever on StackOverflow) is: With the Bot back-end
and the webchat client front-end being hosted in the same, single
Azure web app, is it necessary to use DirectLine, or is there a
simpler way of doing this?
Yes, it is necessary. In fact, all the channels types are using the Bot Connector to communicate with your backend (your bot code), there is no direct access possible. There are a lot of reasons for that, one is for example the billing!

Related

GRPC-Web + Blazor CORS Issue

I'm trying to create a Blazor WASM application that will call a GRPC gateway using grpc-web.
The description of the Gateway Service is:
syntax = "proto3";
import "Services/AdService.proto";
package BonnieAndClydesdale.Core;
service GatewayService {
rpc GetAds (AdRequest) returns (AdReply);
}
I've followed this guide to set-up grpc-web on server and client.
I have this in the program.cs of my server:
builder.Services.AddGrpc();
builder.Services.AddGrpcReflection();
builder.Services.AddGrpcClient<AdService.AdServiceClient>(o => o.Address = new("https://localhost:7223")); // Downstream GRPC service
WebApplication app = builder.Build();
// Configure the HTTP request pipeline.
app.MapGrpcService<GatewayService>().EnableGrpcWeb();
app.UseGrpcWeb();
And this in the program.cs of my blazor app:
builder.Services.AddSingleton(services =>
{
HttpClient httpClient = new(new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()));
string baseUri = "https://localhost:7080"; // TODO - Add to AppSettings
GrpcChannel channel = GrpcChannel.ForAddress(baseUri, new() { HttpClient = httpClient });
return new GatewayService.GatewayServiceClient(channel);
});
However, when I load this page:
public partial class ForSale: ComponentBase
{
[Inject]
private GatewayService.GatewayServiceClient Client { get; init; } = null!;//TODO replace with service
private readonly List<AdDetails> _ads;
public ForSale()
{
_ads = new();
}
protected override async Task OnInitializedAsync()
{
AdReply? rep = await Client.GetAdsAsync(new());
if (rep.AdDetails is not null)
{
_ads.AddRange(rep.AdDetails);
}
}
}
I'm getting a CORS error:
Access to fetch at
'https://localhost:7080/BonnieAndClydesdale.Core.GatewayService/GetAds'
from origin 'https://localhost:5001' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested
resource. If an opaque response serves your needs, set the request's
mode to 'no-cors' to fetch the resource with CORS disabled.
Any ideas on how to approach fixing this?
EDIT
I've seen a lot of answers to similar questions that suggest using services.AddCors(...) and app.UseCors() but these methods don't seem to exist on Blazor WASM apps.
The issue arose from a misunderstanding as to where the CORS policy needed to be set. It needed to be set in the gateway server rather than on the Blazor WASM web-app. This makes sense since CORS is implemented on the server but the confusion arose because most tutorials seem to assume that we're using Blazor Server.
For the sake of completeness, I added this to the startup.cs of my gateway (not the Blazor App).
const string corsPolicy = "_corsPolicy";
builder.Services.AddCors(options =>
{
options.AddPolicy(name: corsPolicy,
policy =>
{
policy.WithOrigins("https://localhost:5001",
"http://localhost:5000")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
WebApplication app = builder.Build();
app.UseCors(corsPolicy);

Oauth2 token, handle from JS spa from MVC

About OAuth.
Frontend SPA react
MVC OAuth backend, signs user's into 3rd party providers, works nicely, return token.
from my SPA I can do window.open and redirect the user to a sign-in page, NB: has to be a new window as xframeoptions is set to deny.
How do I return the token & correlate with SPA, as they are in separate windows/sessions?
options I'm looking at
content security policy - set the caller's domain
set same site cookie
Using aspnet-contrib/AspNet.Security.OAuth.Providers
Samples
Startup.cs
public class Startup
{
private const string policyName = "Cors";
public Startup(IConfiguration configuration, IHostEnvironment hostingEnvironment)
{
Configuration = configuration;
HostingEnvironment = hostingEnvironment;
}
public IConfiguration Configuration { get; }
private IHostEnvironment HostingEnvironment { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddRouting();
services.AddCors(opt =>
{
opt.AddPolicy(name: policyName, builder =>
{
builder.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyOrigin()
.AllowAnyMethod();
});
});
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/signin";
options.LogoutPath = "/signout";
})
.AddGitHub(options =>
{
options.ClientId = Configuration["GitHub:ClientId"];
options.ClientSecret = Configuration["GitHub:ClientSecret"];
options.Scope.Add("user:email");
options.Scope.Add("read:org");
options.Scope.Add("workflow");
options.SaveTokens=true;
});
services.AddMvc();
}
public void Configure(IApplicationBuilder app)
{
if (HostingEnvironment.IsDevelopment())
{
// IdentityModelEventSource.ShowPII = true;
}
// Required to serve files with no extension in the .well-known folder
//var options = new StaticFileOptions()
//{
// ServeUnknownFileTypes = true,
//};
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
});
app.UseCors(policyName);
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
});
Authentication Controller
public class AuthenticationController : Controller
{
[HttpGet("~/signin")]
public async Task<IActionResult> SignIn() => View("SignIn", await HttpContext.GetExternalProvidersAsync());
[HttpPost("~/signin")]
public async Task<IActionResult> SignIn([FromForm] string provider)
{
// Note: the "provider" parameter corresponds to the external
// authentication provider choosen by the user agent.
if (string.IsNullOrWhiteSpace(provider))
{
return BadRequest();
}
if (!await HttpContext.IsProviderSupportedAsync(provider))
{
return BadRequest();
}
// Instruct the middleware corresponding to the requested external identity
// provider to redirect the user agent to its own authorization endpoint.
// Note: the authenticationScheme parameter must match the value configured in Startup.cs
return Challenge(new AuthenticationProperties { RedirectUri = "/" }, provider);
}
[HttpGet("~/signout")]
[HttpPost("~/signout")]
public IActionResult SignOutCurrentUser()
{
// Instruct the cookies middleware to delete the local cookie created
// when the user agent is redirected from the external identity provider
// after a successful authentication flow (e.g Google or Facebook).
return SignOut(new AuthenticationProperties { RedirectUri = "/" },
CookieAuthenticationDefaults.AuthenticationScheme);
}
}
Home Controller
public class HomeController : Controller
{
public async Task<IActionResult> IndexAsync()
{
var accessToken = await HttpContext.GetTokenAsync("GitHub", "access_token");
var refreshToken = await HttpContext.GetTokenAsync("GitHub", "refresh_token");
return View();
}
}
Home Page (Index.cshtml)
<div class="jumbotron">
#if (User?.Identity?.IsAuthenticated ?? false)
{
<h1>Welcome, #User.Identity.Name</h1>
<p>
#foreach (var claim in Context.User.Claims)
{
<div><code>#claim.Type</code>: <strong>#claim.Value</strong></div>
}
</p>
<a class="btn btn-lg btn-danger" href="/signout?returnUrl=%2F">Sign out</a>
}
else
{
<h1>Welcome, anonymous</h1>
<a class="btn btn-lg btn-success" href="/signin?returnUrl=%2F">Sign in</a>
}
</div>
Thanks for looking
It seems (correct me if I'm mistaken) that the main issue is launching the window for github auth and sending that token back to your site which is a different window (i.e. the parent window of the popup).
One option is to set up your auth request's redirect_uri parameter so that the 3rd party identity provider redirects back to a URL on your site (within the popup window), i.e. your ~/signin endpoint. This allows your server-side to grab hold of the tokens and do things like create a session, store a cookie, etc. Cookies set within the popup window will be available to your site in the original window (assuming it's the same domain) once the parent window has refreshed.
Next, once the popup window has been redirected back to your ~/signin endpoint and you've created the session or stored a cookie, etc, you may wish to close that popup and refresh the parent window so that it recognises the cookie / new session. You can do this by returning a page from the ~/signin request (still inside the popup) which includes the following JavaScript:
window.opener.document.location.reload();
// alternatively send the user to an authenticated homepage:
window.opener.document.location.href = '/signed-in-user-homepage';
// and then close the popup
window.close();
I don't think it's your goal but for completeness, if you wish to perform OAuth authentication/authorisation from the SPA itself, so the server side doesn't get the tokens, you may wish to let a Javascript library like oidc-client do the heavy lifting. This launches its own window to perform authentication and hands back the token to the calling SPA itself. These tokens are not visible to the server side.
One resource which really helped me figure this out was the IdentityServer4 quickstart for JavaScript clients, worth stepping through if this is your use case:
OpenID Connect auth from JavaScript clients quickstart
Sample code for the above quickstart
Once you've got a token on the client side (i.e. in JavaScript), you could pass it to the server side to use to make requests on behalf of the user but this isn't good practice as it impersonation rather than properly delegated authorisation, and you'd need to work out what to do when the access token expires (passing the refresh token to the server side effectively allows your server to impersonate the user indefinitely).
My preference is to allow the server-side to perform an auth-code authorisation flow using AspNet.Security.OAuth.Providers, receive the auth code to the ~/signin page, perform the back channel request to get the tokens, use the identity information to create my own profile for the user and perform requests on behalf of the user using authorisation delegated to my service.
Let me know if any of this needs more explaining but hope it's useful as-is.

How to do integration tests with AzureAD, if 2FA is enabled

I am trying to set up integration tests in a project, where the application is secured by AzureAd (Microsoft.Identity.Web) and 2FA. Usually the user would log in to the application using a SPA, and would be greeted by the login popup or redirect. While testing controller endpoints, there is no SPA and we are not using the On-behalf-of flow, so I thought I could simply obtain a token the usual way for testing purposes:
[HttpPost]
public async Task<IActionResult> GetTokenAsync([FromBody] TokenRequest model)
{
_client.BaseAddress = new Uri("https://login.microsoftonline.com/");
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
/* ... */
}
var response = await _client.PostAsync($"{_options.TenantId}/oauth2/v2.0/token", content);
var responseContent = await response.Content.ReadAsStringAsync();
var token = JsonConvert.DeserializeObject<dynamic>(responseContent);
return Ok(new TokenResponse { Token = token.id_token });
}
However, due to 2FA being enabled, I am receiving the following error:
{{
"error": "invalid_grant",
"error_description": "AADSTS50158: External security challenge not satisfied. User will be redirected to another page or authentication provider to satisfy additional authentication
"error_codes": [
50158
],
"timestamp": "2020-12-04 12:41:10Z",
"trace_id": "xxxxxxxxxxxxxx",
"correlation_id": "xxxxxxxxxxxxxxxxxxx",
"error_uri": "https://login.microsoftonline.com/error?code=50158",
"suberror": "basic_action",
"claims": "{ /* ... */}"
}}
A more human-readable-friendly version: https://login.microsoftonline.com/error?code=50158
Here is the relevant part for the authentication configuration:
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddMicrosoftIdentityWebApi(this.Configuration)
.EnableTokenAcquisitionToCallDownstreamApi()
.AddMicrosoftGraph(this.Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();
/* ... */
}
Is there a way to satisfy the 2FA requirement during testing or circumvent it, while keeping the basic token mechanism intact?
Update
To clear up any possible confusion: I'm targeting automated tests, without human intervention. I know, that I can use a mock authentication scheme with a custom WebApplicationFactory, but I'd go for a less fake approach if possible.

Microsoft Authentication in ASP.NET Core 2 and Azure App Services

I have the following application at GitHub and have deployed it to https://stratml.services on an Azure App Service with Authentication defined as Microsoft Account with anymous requests requiring a Microsoft Account sign in. In "prod" this challenge occurs, however https://stratml.services/Home/IdentityName returns no content.
I have been following this and this however I do not want to use EntityFramework and from the latter's description it seems to imply if I configure my Authentication scheme correctly I do not have to.
This following code is in my Start class:
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = MicrosoftAccountDefaults.AuthenticationScheme;
}).AddMicrosoftAccount(microsoftOptions =>
{
microsoftOptions.ClientId = Configuration["Authentication:AppId"];
microsoftOptions.ClientSecret = Configuration["Authentication:Key"];
microsoftOptions.CallbackPath = new PathString("/.auth/login/microsoftaccount/callback");
});
Update: Thanks to the first answer I was able to get, it now authorizes to Microsoft and attempts to feedback to my application however I receive the following error:
InvalidOperationException: No IAuthenticationSignInHandler is configured to handle sign in for the scheme: Cookies
Please visit https://stratml.services/Home/IdentityName and the GitHub has been updated.
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = MicrosoftAccountDefaults.AuthenticationScheme;
}).AddCookie(option =>
{
option.Cookie.Name = ".myAuth"; //optional setting
}).AddMicrosoftAccount(microsoftOptions =>
{
microsoftOptions.ClientId = Configuration["Authentication:AppId"];
microsoftOptions.ClientSecret = Configuration["Authentication:Key"];
});
I have checked this issue on my side, based on my test, you could confgure your settings as follows:
Under the ConfigureServices method, add the cookie and MSA authentication services.
services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = MicrosoftAccountDefaults.AuthenticationScheme;
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(option =>
{
option.Cookie.Name = ".myAuth"; //optional setting
})
.AddMicrosoftAccount(microsoftOptions =>
{
microsoftOptions.ClientId = Configuration["Authentication:AppId"];
microsoftOptions.ClientSecret = Configuration["Authentication:Key"];
});
Under the Configure method, add app.UseAuthentication().
TEST:
[Authorize]
public IActionResult Index()
{
return Content(this.User.Identity.Name);
}
When I checking your online website, I found that you are using the Authentication and authorization in Azure App Service and Authenticate with Microsoft account.
AFAIK, when using the app service authentication, the claims could not be attached to current user, you could retrieve the identity name via Request.Headers["X-MS-CLIENT-PRINCIPAL-NAME"] or you could follow this similar issue to manually attach all claims for current user.
In general, you could either manually enable authentication middle-ware in your application or just leverage the app service authentication provided by Azure without changing your code for enabling authentication. Moreover, you could Remote debugging web apps to troubleshoot with your application.
UPDATE:
For enable the MSA authentication in my code and test it when deployed to azure, I disabled the App Service Authentication, then deployed my application to azure web app. I opened a new incognito window and found that my web app could work as expected.
If you want to simulate the MSA login locally and use Easy Auth when deployed to azure, I assumed that you could set a setting value in appsettings.json and manually add the authentication middle-ware for dev and override the setting on azure, details you could follow here. And you could use the same application Id and configure the following redirect urls:
https://stratml.services/.auth/login/microsoftaccount/callback //for easy auth
https://localhost:44337/signin-microsoft //manually MSA authentication for dev locally
Moreover, you could follow this issue to manually attach all claims for current user. Then you could retrieve the user claims in the same way for the manually MSA authentication and Easy Auth.
If you are using App Service Authentication (EasyAuth), according to Microsoft documentation page:
App Service passes some user information to your application by using special headers. External requests prohibit these headers and will only be present if set by App Service Authentication / Authorization. Some example headers include:
X-MS-CLIENT-PRINCIPAL-NAME
X-MS-CLIENT-PRINCIPAL-ID
X-MS-TOKEN-FACEBOOK-ACCESS-TOKEN
X-MS-TOKEN-FACEBOOK-EXPIRES-ON
Code that is written in any language or framework can get the information that it needs from these headers. For ASP.NET 4.6 apps, the ClaimsPrincipal is automatically set with the appropriate values.
So basically, if you are using ASP.NET Core 2.0, you need to set the ClaimPrincipal manually. What you need to use in order to fetch this headers and set the ClaimsPrincipal is AuthenticationHandler
public class AppServiceAuthenticationOptions : AuthenticationSchemeOptions
{
public AppServiceAuthenticationOptions()
{
}
}
internal class AppServiceAuthenticationHandler : AuthenticationHandler<AppServiceAuthenticationOptions>
{
public AppServiceAuthenticationHandler(
IOptionsMonitor<AppServiceAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
return Task.FromResult(FetchAuthDetailsFromHeaders());
}
private AuthenticateResult FetchAuthDetailsFromHeaders()
{
Logger.LogInformation("starting authentication handler for app service authentication");
if (Context.User == null || Context.User.Identity == null || Context.User.Identity.IsAuthenticated == false)
{
Logger.LogDebug("identity not found, attempting to fetch from the request headers");
if (Context.Request.Headers.ContainsKey("X-MS-CLIENT-PRINCIPAL-ID"))
{
var headerId = Context.Request.Headers["X-MS-CLIENT-PRINCIPAL-ID"][0];
var headerName = Context.Request.Headers["X-MS-CLIENT-PRINCIPAL-NAME"][0];
var claims = new Claim[] {
new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", headerId),
new Claim("name", headerName)
};
Logger.LogDebug($"Populating claims with id: {headerId} | name: {headerName}");
var identity = new GenericIdentity(headerName);
identity.AddClaims(claims);
var principal = new GenericPrincipal(identity, null);
var ticket = new AuthenticationTicket(principal,
new AuthenticationProperties(),
Scheme.Name);
Context.User = principal;
return AuthenticateResult.Success(ticket);
}
else
{
return AuthenticateResult.Fail("Could not found the X-MS-CLIENT-PRINCIPAL-ID key in the headers");
}
}
Logger.LogInformation("identity already set, skipping middleware");
return AuthenticateResult.NoResult();
}
}
You can then write an extension method for the middleware
public static class AppServiceAuthExtensions
{
public static AuthenticationBuilder AddAppServiceAuthentication(this AuthenticationBuilder builder, Action<AppServiceAuthenticationOptions> configureOptions)
{
return builder.AddScheme<AppServiceAuthenticationOptions, AppServiceAuthenticationHandler>("AppServiceAuth", "Azure App Service EasyAuth", configureOptions);
}
}
And add app.UseAuthentication(); in the Configure() method and put following in the ConfigureServices() method of your startup class.
services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "AppServiceAuth";
options.DefaultChallengeScheme = "AppServiceAuth";
})
.AddAppServiceAuthentication(o => { });
If you need full claims details, you can retrieve it on the AuthenticationHandler by making request to /.auth/me and use the same cookies that you've received on the request.

IdentityServer3 - rejected because invalid CORS path

We have an ASP.NET MVC application that is authenticating without issue against IdentityServer3, however the web API part of the application using ApiController's start to fail if the user waits before proceeding with AJAX functionality after about 3 minutes (before 3 mins everything seems fine).
The errors seen in Chrome are:
XMLHttpRequest cannot load
https://test-auth.myauthapp.com/auth/connect/authorize?client_id=ecan-farmda…gwLTk5ZjMtN2QxZjUyMjgxNGE4MDg2NjFhZTAtOTEzNi00MDE3LTkzNGQtNTc5ODAzZTE1Mzgw.
No 'Access-Control-Allow-Origin' header is present on the requested
resource. Origin 'http://test.myapp.com' is therefore not allowed
access.
On IE I get the following errors:
SCRIPT7002: XMLHttpRequest: Network Error 0x4c7, The operation was
canceled by the user.
Looking at IdentityServer3's logs I'm seeing entries like so:
2015-08-10 16:42 [Warning]
(Thinktecture.IdentityServer.Core.Configuration.Hosting.CorsPolicyProvider)
CORS request made for path: /connect/authorize from origin:
http://test.myapp.com but rejected because invalid CORS path
In the IdentityServer3 web application I'm giving clients AllowedCorsOrigins:
Thinktecture.IdentityServer.Core.Models.Client client = new Thinktecture.IdentityServer.Core.Models.Client()
{
Enabled = configClient.Enabled,
ClientId = configClient.Id,
ClientName = configClient.Name,
RedirectUris = new List<string>(),
PostLogoutRedirectUris = new List<string>(),
AllowedCorsOrigins = new List<string>(),
RequireConsent = false, // Don't show consents screen to user
RefreshTokenExpiration = Thinktecture.IdentityServer.Core.Models.TokenExpiration.Sliding
};
foreach (Configuration.RegisteredUri uri in configClient.RedirectUris)
{
client.RedirectUris.Add(uri.Uri);
}
foreach (Configuration.RegisteredUri uri in configClient.PostLogoutRedirectUris)
{
client.PostLogoutRedirectUris.Add(uri.Uri);
}
// Quick hack to try and get CORS working
client.AllowedCorsOrigins.Add("http://test.myapp.com");
client.AllowedCorsOrigins.Add("http://test.myapp.com/"); // Don't think trailing / needed, but added just in case
clients.Add(client);
And when registering the service I add a InMemoryCorsPolicyService:
app.Map("/auth", idsrvApp =>
{
var factory = new IdentityServerServiceFactory();
factory.Register(new Registration<AuthContext>(resolver => AuthObjects.AuthContext));
factory.Register(new Registration<AuthUserStore>());
factory.Register(new Registration<AuthRoleStore>());
factory.Register(new Registration<AuthUserManager>());
factory.Register(new Registration<AuthRoleManager>());
// Custom user service used to inject custom registration workflow
factory.UserService = new Registration<IUserService>(resolver => AuthObjects.AuthUserService);
var scopeStore = new InMemoryScopeStore(Scopes.Get());
factory.ScopeStore = new Registration<IScopeStore>(scopeStore);
var clientStore = new InMemoryClientStore(Clients.Get());
factory.ClientStore = new Registration<IClientStore>(clientStore);
var cors = new InMemoryCorsPolicyService(Clients.Get());
factory.CorsPolicyService = new Registration<ICorsPolicyService>(cors);
...
var options = new IdentityServerOptions
{
SiteName = "Authentication",
SigningCertificate = LoadCertificate(),
Factory = factory,
AuthenticationOptions = authOptions
};
...
});
I do note that the IdentityServer3 log entries say "CORS request made for path: /connect/authorize" rather than "CORS request made for path: /auth/connect/authorize". But looking through the IdentityServer3 source code suggests this probably isn't the issue.
Perhaps the InMemoryCorsPolicyService isn't being picked up?
Any ideas of why things aren't working for the AJAX called ApiController?
Thinktecture.IdevtityServer3 v1.6.2 has been installed using NuGet.
Update
I'm having a conversation with the IdentityServer3 developer, but am still having an issue reaching a resolution. In case it helps:
https://github.com/IdentityServer/IdentityServer3/issues/1697
Did you try adding https url also?- client.AllowedCorsOrigins.Add("https://test.myapp.com");
The documentation of IdentityServer says you should configure it on the client:
AllowedCorsOrigins = ... // Defaults to the discovery, user info, token, and revocation endpoints.
https://docs.duendesoftware.com/identityserver/v6/reference/options/#cors
CORS is a nightmare!
It's a browser thing which is why you're witnessing different behaviour in IE than in Chrome.
There are (at least) two ways that CORS is configured on the server. When a client makes a request with the Origin header you have to tell the server whether or not to accept it -- if accepted then the server adds the Access-Control-Allow-Origin header to the response for the browser.
In MVC / webAPI you have to add CORS services, set a CORS policy, and then .UseCors something like this:
builder.Services.AddCors((options =>
{
if (settings.AllowedCorsOrigins.Length > 0)
{
options.AddDefaultPolicy(builder =>
{
builder.SetIsOriginAllowedToAllowWildcardSubdomains();
builder.AllowAnyHeader().AllowAnyMethod().WithOrigins(settings.AllowedCorsOrigins);
});
}
if (isDevelopment)
{
options.AddPolicy("localhost", builder =>
{
builder.SetIsOriginAllowedToAllowWildcardSubdomains();
builder.AllowAnyHeader().AllowAnyMethod().SetIsOriginAllowed((string origin) => { return origin.Contains("localhost"); }); });
}
});
and
app.UseCors();
if (app.Environment.IsDevelopment())
{
app.UseCors("localhost");
}
Typically, you want the list of allowed hosts as an array of strings in your appsettings.json. And watch out for the boobytrap with SetIsOriginAllowedToAllowWildcardSubdomains.
As well as this, IdentityServer has its own additional CORS settings which are applied in addition to the standard MVC/webAPI settings. These are in the ClientCorsOrigin table and this doesn't support wildcard subdomains. You can sidestep this whole boobytrap by implementing your own ICorsPolicyService to use the same settings from your appsettings.json something like this
public class CorsPolicyService : ICorsPolicyService
{
private readonly CorsOptions _options;
public CorsPolicyService(IOptions<CorsOptions> options)
{
_options = options.Value;
}
private bool CheckHost(string host)
{
foreach (string p in _options.AllowedCorsOrigins)
{
if (Regex.IsMatch(host, Regex.Escape(p).Replace("\\*", "[a-zA-Z0-9]+"))) // Hyphen?
{
return true;
}
}
return false;
}
public Task<bool> IsOriginAllowedAsync(string origin)
{
return Task.FromResult(CheckHost(origin));
}
}

Categories