ApiController redirects to login page
Describe the bug
When using ASP.NET Identity cookie authentication to protect a API controller that is decorated with the ApiController attribute and performing an unauthorized HTTP request, ASP.NET redirects to /Identity/Account/Login?ReturnUrl=%2Fapi%2FFoo instead of just returning a 401 Unauthorized status code and problem details in JSON format.
To Reproduce
Steps to reproduce the behavior:
- Configure ASP.NET Core to use ASP.NET Identity with default identity and default UI which uses cookies.
- Create a API controller that you decorate with the
ApiControllerattribute. - Perform a HTTP request (Content-Type can even be application/json) against the controller.
Using ASP.NET Core 6.0.
[ApiController] // <-- I want this controller to behave like an API.
[Authorize] // <-- Authenticate using cookies.
[Route("api/[controller]")]
public class FooController : ControllerBase
{
[HttpGet]
public string Get()
{
return "string";
}
}
// Add services to the container.
/// ...
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
// ...
// Configure the HTTP request pipeline.
// ...
app.UseAuthentication();
// ...
Expected behavior
I expected the API to behave as an API since I explicitly decorated the controller with the ApiController attribute.
I expected it to return the status code 401 Unauthorized.
Maybe with a link to: https://httpstatuses.com/401
Additional context
My SPA authenticates using cookie authentication then uses fetch() to do HTTP requests against the API. The fetch call sets the credentials option to include cookies with the request.
Override the cookie authentication events.
As you're also using identity, it'd be something like
services.ConfigureApplicationCookie(o =>
{
o.Events = new CookieAuthenticationEvents()
{
OnRedirectToLogin = (ctx) =>
{
if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
{
ctx.Response.StatusCode = 401;
}
return Task.CompletedTask;
},
OnRedirectToAccessDenied = (ctx) =>
{
if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
{
ctx.Response.StatusCode = 403;
}
return Task.CompletedTask;
}
};
});
cc @DamianEdwards and @davidfowl since this is similar to the diagnostics \ error handling that you recently investigated.
Thank you @blowdart for the workaround.
I still think that controllers intended for use as APIs and explicitly decorated with the ApiController should return a status code (possibly along with JSON) instead of redirecting though.
That's an interesting idea @vanillajonathan. That responsibility lies outside of the authentication pieces, and in MVC, so I'll demure to @rynowak et al for that.
@glennc
@vanillajonathan Actually this is happening due to a complete miss-design of introducing Razor Page Identity in the MVC application. And they are sticking to this miss-design and trying to fit this in everywhere. In Angular application they are providing login page from Razor Page Identity. How comical is this! :) What will happen if my Client App and API are in two different apps?
This hodgepodge would not have happened if they didn't introduce Razor Page Identity in the MVC application. By the way, I completely hate this Razor Page Identity in an MVC application and I am fully irritated with this.
+1 Would be great if we could stop using that workaround suggested by @blowdart. I lost the count in how many applications I've added that piece of code.
If you send the head "X-Requested-With: XMLHttpRequest" the redirection is not done and only 401 is send cf https://github.com/aspnet/AspNetCore/blob/master/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs.
I tried it with a Blazor client-side app and it is working
Thank you @RemiBou, I was not aware of that. I just did a plain fetch request without setting any additional HTTP headers.
A thing to take into consideration is that ASP.NET Core MVC no longer have the IsAjaxRequest method that was present in the old ASP.NET MVC 5, so arguably have been moving away from the X-Requested-With header which is a non-standard (but de facto standard) used by jQuery (and some other JavaScript frameworks and libraries).
Another thing to note is that I believe including the X-Requested-With header would or could trigger an additional CORS preflight request.
Why do you think the headers won't change the CORS preflight ?
My understanding is that adding non-standard HTTP headers to a fetch causes it perform a CORS preflight request.
Why do you think the headers won't change the CORS preflight ?
See: https://developers.google.com/web/ilt/pwa/working-with-the-fetch-api#example_post_requests
The server in this example would need to be configured to accept the X-Custom-Header header in order for the fetch to succeed. When a custom header is set, the browser performs a preflight check. This means that the browser first sends an OPTIONS request to the server to determine what HTTP methods and headers are allowed by the server.
@blowdart solution doesn't work in .NET Core 3.x (preview) anymore. I tried a ton of different approaches but can't seem to find a working solution. Annoying. A Web API project redirecting "automagically" (huge design mistake) to a login page which does not exist and therefore ends up in returning a 404 (Not Found) whilst actually the request was UnAuthenticated (which should be the real name of 401 UnAuthorized. Authentication -> Who are you? And do you have a valid login with password? Authorization -> Hey we know you, welcome! But do you have the right to perform this request? Well let's see which you roles / claims you own.
Concerning the flaw in design I agree with @TanvirArjel completely.
@Bernoulli-IT, well, I have this issue even for Bearer auth in ASP.NET Core 3
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ClockSkew = TimeSpan.FromMinutes(5),
RequireSignedTokens = true,
RequireExpirationTime = true,
ValidateLifetime = true,
ValidateAudience = false,
ValidIssuer = issuer,
IssuerSigningKey = new X509SecurityKey(certificate),
ValidateIssuerSigningKey = true,
NameClaimType = "sub"
};
opt.IncludeErrorDetails = true;
});
And I saw a redirect instead of 401 for the controller marked as ApiController
icrosoft.AspNetCore.Hosting.Diagnostics: Information: Request starting HTTP/1.1 GET http://localhost:53775/identity/api/claims/158f6e60-cab9-47a2-9e91-7b124c45945b
Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware: Trace: All hosts are allowed. Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware: Debug: The request path does not match the path filter Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. IdentityServer4.Hosting.EndpointRouter: Trace: No endpoint entry found for request path: /api/claims/158f6e60-cab9-47a2-9e91-7b124c45945b Microsoft.AspNetCore.Routing.Matching.DfaMatcher: Debug: 1 candidate(s) found for the request path '/api/claims/158f6e60-cab9-47a2-9e91-7b124c45945b' Microsoft.AspNetCore.Routing.Matching.DfaMatcher: Debug: Endpoint 'Luscii.Identity.Service.Controllers.ClaimController.Get (Luscii.Identity.Service)' with route pattern 'api/claims/{connectId}' is valid for the request path '/api/claims/158f6e60-cab9-47a2-9e91-7b124c45945b' Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware: Debug: Request matched endpoint 'Luscii.Identity.Service.Controllers.ClaimController.Get (Luscii.Identity.Service)' Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. Microsoft.AspNetCore.Authorization.DefaultAuthorizationService: Information: Authorization failed. Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Information: AuthenticationScheme: idsrv was challenged. Microsoft.AspNetCore.Hosting.Diagnostics: Information: Request finished in 440.71200000000005ms 302 Microsoft.AspNetCore.Server.IIS.Core.IISHttpServer: Debug: Connection ID "18230571301796315137" disconnecting. Microsoft.AspNetCore.Hosting.Diagnostics: Information: Request starting HTTP/1.1 GET http://localhost:53775/identity/Account/Login?ReturnUrl=%2Fidentity%2Fapi%2Fclaims%2F158f6e60-cab9-47a2-9e91-7b124c45945b
Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware: Trace: All hosts are allowed. Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware: Debug: The request path does not match the path filter Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. IdentityServer4.Hosting.EndpointRouter: Trace: No endpoint entry found for request path: /Account/Login Microsoft.AspNetCore.Routing.Matching.DfaMatcher: Debug: No candidates found for the request path '/Account/Login' Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware: Debug: Request did not match any endpoints Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler: Debug: AuthenticationScheme: idsrv was not authenticated. Microsoft.AspNetCore.Hosting.Diagnostics: Information: Request finished in 117.48580000000001ms 404
But what was really suspicious is idsrv scheme. It appeared, that I was adding (!!!) bearer as default auth scheme (I use [Authorize] without specifying scheme), and only then I was adding IdentityServer services where default auth scheme gets silently overridden.
When I changed the order of services registration, 401 was returned instead of redirect.
Redirect happens.
AddJwtAuthentication(services);
AddIdentityServer(services);
401 is returned
AddIdentityServer(services);
AddJwtAuthentication(services);
I would like to see warnings, if default scheme changes multiple times.
Maybe @leastprivilege and @brockallen are willing to comment on this as well.
I don't need to use IdentityServer. This is what worked for me:
services
.AddAuthentication()
.AddOpenIdConnect()
.AddJwtBearer()
.AddCookie(options =>
{
options.Events.OnRedirectToAccessDenied =
options.Events.OnRedirectToLogin = c =>
{
c.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.FromResult<object>(null);
};
});
The framework is great but when I start working with security it is a total miss this is what make me avoid asp .net for many years ... you need so much workarounds to accomplish your work.
I think specially the Security part in asp .net should be re designed is there any alternative to it?
Hi there! I just wasted way too much time on this issue. In my case, depending on which the configuration of the project, Authentification is necessary or not etc., and I don't use IdentityServer etc.
I was using netcore3.1
// you have to assign any scheme, not assigning a scheme doesn't throw but also doesn't work
app.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
// necessary, even if you don't use BearerAuth.
.AddJwtBearer();
app.UseRouting();
// UseAuthentification & UseAuthorization must be called after Routing and before Endpoints!
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(...);
For standalone operation without Authentification, you need to write a class and implement IAuthorizationService and return AuthorizationResult.Success() in every case.
Of course, you need to add the service manually to services (e.g. with AddTransient).
Either way, it took way too long to get my backend working. To me, there's a huge usability problem for developers because the default behavior isn't really expectable and there's not really much helpful documentation out there. Due to AspNetCore being OpenSource at least, I was able to somewhat try and guess how to get it working.
I don't need to use IdentityServer. This is what worked for me:
services .AddAuthentication() .AddOpenIdConnect() .AddJwtBearer() .AddCookie(options => { options.Events.OnRedirectToAccessDenied = options.Events.OnRedirectToLogin = c => { c.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.FromResult<object>(null); }; });
👍Suppressed redirecting using this method. Nice!
@RemiBou unfortunately I cannot reproduce that setting the X-Requested-With: XMLHttpRequest header causes a 401. I have created a fresh "ASP.NET Core Web Application" project (the "ASP.NET Core Web API" template) and applied this diff:
index 0e4d663..f7dade8 100644
--- a/NoRedirectForXHRTest/Controllers/WeatherForecastController.cs
+++ b/NoRedirectForXHRTest/Controllers/WeatherForecastController.cs
@@ -1,4 +1,5 @@
-<EF><BB><BF>using Microsoft.AspNetCore.Mvc;
+<EF><BB><BF>using Microsoft.AspNetCore.Authorization;^M
+using Microsoft.AspNetCore.Mvc;^M
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
@@ -7,6 +8,7 @@ using System.Threading.Tasks;
namespace NoRedirectForXHRTest.Controllers
{
+ [Authorize]^M
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
diff --git a/NoRedirectForXHRTest/Startup.cs b/NoRedirectForXHRTest/Startup.cs
index e1960f1..16ffd18 100644
--- a/NoRedirectForXHRTest/Startup.cs
+++ b/NoRedirectForXHRTest/Startup.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Authentication.Cookies;^M
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
@@ -24,7 +25,9 @@ namespace NoRedirectForXHRTest
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
-
+ services.AddAuthentication(options =>^M
+ options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme)^M
+ .AddCookie();^M
services.AddControllers();
}
When I query the forecast controller with curl and the header set, I get redirected to the (not existing) loginpage:
~$ curl -X GET "http://localhost:5000/weatherforecast" -v -H "X-Requested-With: XMLHttpRequest"
Note: Unnecessary use of -X or --request, GET is already inferred.
* Trying 127.0.0.1:5000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 5000 (#0)
> GET /weatherforecast HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.68.0
> Accept: */*
> X-Requested-With: XMLHttpRequest
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 Unauthorized
< Date: Wed, 18 Nov 2020 16:02:06 GMT
< Server: Kestrel
< Content-Length: 0
< Location: http://localhost:5000/Account/Login?ReturnUrl=%2Fweatherforecast
<
* Connection #0 to host localhost left intact
Am I missing something obvious? The header is parsed correctly, a test controller like this echos "foobar true":
[HttpGet]
public ActionResult Test()
{
var foo = string.Equals(HttpContext.Request.Query[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal) ||
string.Equals(HttpContext.Request.Headers[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal);
return Ok($"foobar {foo}");
}
Maybe if we patch the file CookieAuthenticationEvents.cs file:
Checking if the Accept request header contains "application/json".
/// <summary>
/// Invoked when the client needs to be redirected to the sign in url.
/// </summary>
public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToLogin { get; set; } = context =>
{
+ if (context.Request.Headers.TryGetValue(HeaderNames.Accept, out StringValues accept) && accept.Any(x => x.Contains("application/json")))
+ {
+ context.Response.StatusCode = 401;
+ return Task.CompletedTask;
+ }
if (IsAjaxRequest(context.Request))
{
context.Response.Headers[HeaderNames.Location] = context.RedirectUri;
context.Response.StatusCode = 401;
}
else
{
context.Response.Redirect(context.RedirectUri);
}
return Task.CompletedTask;
};
Or by checking if the path starts with /api/:
/// <summary>
/// Invoked when the client needs to be redirected to the sign in url.
/// </summary>
public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToLogin { get; set; } = context =>
{
+ if (context.Request.Path.StartsWithSegments("/api"))
+ {
+ context.Response.StatusCode = 401;
+ return Task.CompletedTask;
+ }
if (IsAjaxRequest(context.Request))
{
context.Response.Headers[HeaderNames.Location] = context.RedirectUri;
context.Response.StatusCode = 401;
}
else
{
context.Response.Redirect(context.RedirectUri);
}
return Task.CompletedTask;
};
@blowdart, @rynowak, @DamianEdwards, @davidfowl, @glennc, @pranavkm, @mkArtakMSFT
Hey @blowdart, @rynowak, @DamianEdwards, @davidfowl, @glennc, @pranavkm, @mkArtakMSFT What do you think of the patch above? Does it look reasonable? Can this be a way forward?
In my opinion, both proposals are too application specific.
- What if it's a static translation json file or static sensitive json data that needs a redirect to login?
- I can imagine not everyone uses the
/apiprefix, it would be too much magic.
I wonder why the IsAjaxRequest check isn't sufficient for your application?
Good points.
I use fetch in JavaScript and by default it does not include the non-standard X-Requested-With header. See also: https://github.com/dotnet/aspnetcore/issues/9039#issuecomment-501720620
At some point you'll need to wrap the calls to fetch in your own method where you would also handle the 401 that comes back. Otherwise, you'll have to handle the 401 at all places, am I correct?
Once you have that wrapping method, it's easy to include the de-facto X-Requested-With which isn't going away anytime soon IMO. 🙂
yes I want RedirectToLogin on View Controller and 401 on Api Controller
You can also specify the AuthenticationScheme on the controller attribute like this:
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
It would make sense for the ApiController attribute to adjust this behavior, but looking at CookieAuthenticationHandler.cs, SignInAuthenticationHandler.cs, and AuthenticationHandler.cs I have found no obvious way of doing so, I was looking for references to "controller" so I could determine if it was decorated with the ApiController attribute.
allright, I just did it by myself, I just want a simple different authorize on webapi
[AllowAnonymous]
[Route("connect/token")]
[ApiController]
public class TokenController: ControllerBase
{
// issue access_token, asymmetric encryption, or save something in db
}
[ApiAuthorize]
[Route("api/[controller]")]
[ApiController]
public class ValuesController: ControllerBase
{
// protected api controller
}
public class ApiAuthorizeAttribute : Attribute, IAuthorizationFilter, IFilterFactory
{
private AppDbContext _db;
public bool IsReusable => false;
public string AuthenticationSchemes { get; set; }
public string Policy { get; set; }
public string Roles { get; set; }
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
{
return new ApiAuthorizeAttribute()
{
_db = (AppDbContext)serviceProvider.GetService(typeof(AppDbContext));
};
}
public void OnAuthorization(AuthorizationFilterContext context)
{
// check Authorization header
if (context.HttpContext.Request.Headers.ContainsKey("Authorization"))
{
var authHeader = context.HttpContext.Request.Headers["Authorization"][0];
// validate access_token you just issued
// check token expiration
// check token identity valid
// check AuthenticationSchemes
// check Policy
// check Roles
if ( all pass )
return;
}
context.Result = new Microsoft.AspNetCore.Mvc.UnauthorizedResult();
}
}
It was not mentioned, but you can also use the ForbidResult and specify the authentication scheme. So if your API is using Bearer tokens, just use Forbid("Bearer"); You wont get redirected.
[ApiController, Authorize("Bearer")]
public class MyApiController : ControllerBase
{
public IActionResult TestMethod()
{
if (DoSpecialPermissionCheckAfterAuthentication() == false)
{
return Forbid("Bearer");
}
return Ok;
}
}
It does not return the same json object as StatusCode(403), but it does not redirect to the cookies login page.
Roy
I found that @raspyweather's suggestion above worked for me in .NET 5 hosted Blazor Web Assembly (without identity server). Specifically setting the default auth and challenge schemes:
services
.AddAuthentication(o => {
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o => o.TokenValidationParameters = new TokenValidationParameters() {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = this.configuration["JWT:Issuer"],
ValidAudience = this.configuration["JWT:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.configuration["JWT:Key"])),
})
Previously I simply had services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(//jwt sfuff) which resulted in a 302 redirect to a non-existent login page, but changing to the above means I now get the desired 401.
If any one stumbling upon this needs a guide to API auth with hosted Blazor WASM without the rather confusing identity server implementation in the standard template I can't recommend this enough:
https://chrissainty.com/securing-your-blazor-apps-authentication-with-clientside-blazor-using-webapi-aspnet-core-identity/