43

I've created a new ASP.NET Core Web Application project in VS17 using the "Web Application (Model-View-Controller)" template and ".Net Framework" + "ASP.NET Core 2" as the configuration. The authentication config is set to "Individual User Accounts".

I have the following sample endpoint:

[Produces("application/json")]
[Route("api/price")]
[Authorize(Roles = "PriceViwer", AuthenticationSchemes = "Cookies,Bearer")]
public class PriceController : Controller
{

    public IActionResult Get()
    {
        return Ok(new Dictionary<string, string> { {"Galleon/Pound",
                                                   "999.999" } );
    }
}

"Cookies,Bearer" is derived by concatenating CookieAuthenticationDefaults.AuthenticationScheme and JwtBearerDefaults.AuthenticationScheme.

The objective is to be able to configure the authorization for the end point so that it's possible access it using both the token and cookie authentication methods.

Here is the setup I have for Authentication in my Startup.cs:

    services.AddAuthentication()
        .AddCookie(cfg => { cfg.SlidingExpiration = true;})
        .AddJwtBearer(cfg => {
            cfg.RequireHttpsMetadata = false;
            cfg.SaveToken = true;
            cfg.TokenValidationParameters = new TokenValidationParameters() {
                                                    ValidIssuer = Configuration["Tokens:Issuer"],
                                                    ValidAudience = Configuration["Tokens:Issuer"],
                                                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Tokens:Key"]))
                                                };
        });

So, when I try to access the endpoint using a browser, I get the 401 response with a blank html page.
I get the 401 response with a blank html page.

Then I login and when I try to access the endpoint again, I get the same response.

Then, I try to access the endpoint by specifying the bearer token. And that returns the desired result with the 200 response.
And that returns the desired result with the 200 response.

So then, if I remove [Authorize(AuthenticationSchemes = "Cookies,Bearer")], the situation becomes the opposite - cookie authentication works and returns 200, however the same bearer token method as used above doesn't give any results and just redirect to the default AspIdentity login page.

I can see two possible problems here:

1) ASP.NET Core doesn't allow 'combined' authentication. 2) 'Cookies' is not a valid schema name. But then what is the right one to use?

Please advise. Thank you.

JonathanDavidArndt
  • 2,518
  • 13
  • 37
  • 49
maximiniini
  • 433
  • 1
  • 4
  • 5

7 Answers7

25

If I understand the question correctly then I believe that there is a solution. In the following example I am using cookie AND bearer authentication in a single app. The [Authorize] attribute can be used without specifying the scheme, and the app will react dynamically, depending on the method of authorization being used.

services.AddAuthentication is called twice to register the 2 authentication schemes. The key to the solution is the call to services.AddAuthorization at the end of the code snippet, which tells ASP.NET to use BOTH schemes.

I've tested this and it seems to work well.

(Based on Microsoft docs.)

services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddOpenIdConnect("oidc", options =>
    {
        options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.Authority = "https://localhost:4991";
        options.RequireHttpsMetadata = false;

        options.ClientId = "WebApp";
        options.ClientSecret = "secret";

        options.ResponseType = "code id_token";
        options.Scope.Add("api");
        options.SaveTokens = true;
    });

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://localhost:4991";
        options.RequireHttpsMetadata = false;
        // name of the API resource
        options.Audience = "api";
    });

services.AddAuthorization(options =>
{
    var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(
        CookieAuthenticationDefaults.AuthenticationScheme,
        JwtBearerDefaults.AuthenticationScheme);
    defaultAuthorizationPolicyBuilder =
        defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
    options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
});

EDIT

This works for authenticated users, but simply returns a 401 (unauthorized) if a user has not yet logged in.

To ensure that unauthorized users are redirected to the login page, add the following code to the Configure method in your Startup class. Note: it's essential that the new middleware is placed after the call the app.UseAuthentication().

app.UseAuthentication();
app.Use(async (context, next) =>
{
    await next();
    var bearerAuth = context.Request.Headers["Authorization"]
        .FirstOrDefault()?.StartsWith("Bearer ") ?? false;
    if (context.Response.StatusCode == 401
        && !context.User.Identity.IsAuthenticated
        && !bearerAuth)
    {
        await context.ChallengeAsync("oidc");
    }
});

If you know a cleaner way to achieve this redirect, please post a comment!

David Kirkland
  • 2,431
  • 28
  • 28
  • Are you sure this also works for asp.net Core 2.0? It seems, this targets a 3.x version, doesn’t it? – Nikolaus Jan 24 '20 at 20:58
  • What targets ASP.NET Core 3? The version I was testing with at the time of the answer was Core 2.0. – David Kirkland Feb 10 '20 at 19:35
  • Then I have to apologize. – Nikolaus Feb 11 '20 at 19:58
  • `AddOpenIdConnect` is a part of *Identity Server*, does one really need one to get Web log in working? Is this the only way to store the JWT token in a cookie? I'm asking because you can't just add `Microsoft.AspNetCore.Authentication.OpenIdConnect`, it needs some well-known URIs provided by *Identity Server*... – Mike Aug 10 '20 at 20:49
  • @Mike Try it. I was working from documentation, blog posts, etc, last year, but if you can get it working using a more standard, out-of-the-box method then post a new answer. – David Kirkland Sep 07 '20 at 03:06
  • This is working great for controllers, but SignalR in .NET Core 3.1 project, authorizing using JWT but not using Cookie... Any suggestions ? – Bharat Vasant Nov 13 '20 at 13:33
  • Well, this is working in normal web application. If the application with SignalR Hub is child application under the parent IIS application, then only JWT authorization working and Cookie authorization not working. – Bharat Vasant Nov 13 '20 at 16:09
  • how the middleware may be modified to also support XMLHttpRequest() ? In case of ajax calls using XMLHttpRequest, (XMLHttpRequest does not add any header unlike jquery ajax) when the line await context.ChallangeAsync(); is hit, it returns status = 200 and login html page as response to XMLHttpRequest response? – Bharat Vasant Jan 12 '21 at 13:55
  • 2
    Calling `services.AddAuthentication` twice in .Net 5+ results in only the last configuration being used making the above code useless. – N-ate Jun 09 '21 at 02:25
  • When hitting an endpoint protected with JwtBearer without a token, you'd want to get 401. But the above solution will trigger an oidc challenge. Not sure of a cleaner way. I ended up using a custom authentication scheme and then checking path to trigger the appropriate challenge using `AddPolicyScheme` and `ForwardDefaultSelector` approach. – Sophia May 18 '22 at 17:12
  • @N-ate The question was answered in the context of ASP.NET Core 2.2, so sure, it might not be relevant now. If you have a good answer that works with ASP.NET Core 5/6 then add it :) – David Kirkland May 19 '22 at 18:25
22

After many hours of research and head-scratching, this is what worked for me in ASP.NET Core 2.2 -> ASP.NET 5.0:

  • Use .AddCookie() and .AddJwtBearer() to configure the schemes
  • Use a custom policy scheme to forward to the correct Authentication Scheme.

You do not need to specify the scheme on each controller action and will work for both. [Authorize] is enough.

services.AddAuthentication( config =>
{
    config.DefaultScheme = "smart";
} )
.AddPolicyScheme( "smart", "Bearer or Jwt", options =>
{
    options.ForwardDefaultSelector = context =>
    {
        var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith( "Bearer " ) ?? false;
        // You could also check for the actual path here if that's your requirement:
        // eg: if (context.HttpContext.Request.Path.StartsWithSegments("/api", StringComparison.InvariantCulture))
        if ( bearerAuth )
            return JwtBearerDefaults.AuthenticationScheme;
        else
            return CookieAuthenticationDefaults.AuthenticationScheme;
    };
} )
.AddCookie( CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
    options.LoginPath = new PathString( "/Account/Login" );
    options.AccessDeniedPath = new PathString( "/Account/Login" );
    options.LogoutPath = new PathString( "/Account/Logout" );
    options.Cookie.Name = "CustomerPortal.Identity";
    options.SlidingExpiration = true;
    options.ExpireTimeSpan = TimeSpan.FromDays( 1 ); //Account.Login overrides this default value
} )
.AddJwtBearer( JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.RequireHttpsMetadata = false;
    options.SaveToken = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey( key ),
        ValidateIssuer = false,
        ValidateAudience = false
    };
} );

services.AddAuthorization( options =>
{
    options.DefaultPolicy = new AuthorizationPolicyBuilder( CookieAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme )
        .RequireAuthenticatedUser()
        .Build();
} );
N-ate
  • 6,051
  • 2
  • 40
  • 48
Christo Carstens
  • 741
  • 7
  • 14
  • How to have cookie created with access token - the same as Local storage bearer token? Why i need this, because I make `window.open` and need to access logged user identity. I cannot modify headers, so only URL (bad idea) and cookies are available to read token... If I add AddCookie - there is no anything in Cookies after login. Local Storage is filled as usual... AspNetCore 3.1 – Alexander Dec 31 '20 at 17:52
  • 2
    Thanks for this great help. I tried this solution is working JWT + Cookie auth, Cookies to authenticate application pages and JWT to authenticate Bearer token base API calls. Key pick is the way AddPolicyScheme() is working. – Vaibhav.Inspired Feb 16 '21 at 05:22
  • 1
    Using only `config.DefaultScheme = "smart";` has possible undesired effect: if the Jwt auth fails user will be redirected to login page just as with Cookie. To avoid it add `config.DefaultChallengeScheme = "smart";` Read also https://stackoverflow.com/a/51897159/1668552 – Lionet Chen Feb 15 '22 at 06:34
15

I think you don't need to set the AuthenticationScheme to your Controller. Just use Authenticated user in ConfigureServices like this:

// requires: using Microsoft.AspNetCore.Authorization;
//           using Microsoft.AspNetCore.Mvc.Authorization;
services.AddMvc(config =>
{
    var policy = new AuthorizationPolicyBuilder()
                     .RequireAuthenticatedUser()
                     .Build();
    config.Filters.Add(new AuthorizeFilter(policy));
});

For Documentation of my sources: registerAuthorizationHandlers

For the part, whether the scheme-Key wasn't valid, you could use an interpolated string, to use the right keys:

[Authorize(AuthenticationSchemes = $"{CookieAuthenticationDefaults.AuthenticationScheme},{JwtBearerDefaults.AuthenticationScheme}")]

Edit: I did further research and came to following conclusion: It's not possible to authorize a method with two Schemes Or-Like, but you can use two public methods, to call a private method like this:

//private method
private IActionResult GetThingPrivate()
{
   //your Code here
}

//Jwt-Method
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[HttpGet("bearer")]
public IActionResult GetByBearer()
{
   return GetThingsPrivate();
}

 //Cookie-Method
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
[HttpGet("cookie")]
public IActionResult GetByCookie()
{
   return GetThingsPrivate();
}
Mike
  • 7,500
  • 8
  • 44
  • 62
Nikolaus
  • 1,859
  • 1
  • 10
  • 16
  • Thank you the response! Unfortunately this doesn't fix the problem: if I use the config snippet from your comment, and remove AuthenticationScheme from the endpoint decorator, then the standard cookie method works, but the token one doesn't. – maximiniini Oct 27 '17 at 16:26
  • 1
    @maximiniini Did you try to reverse the order? Like: `[Authorize(AuthenticationSchemes = "Bearer,Cookies")]` – Nikolaus Nov 05 '17 at 22:19
  • @maximiniini I updated my answer. Maybe this can help you. – Nikolaus Nov 21 '17 at 15:42
  • Thanks @Nikolaus. I think this is the solution I will use for my project. – maximiniini Nov 22 '17 at 18:08
1

If you have tried the above answers and they are not working for you, try replacing CookieAuthenticationDefaults.AuthenticationScheme with "Identity.Application":

services.AddAuthentication()
    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => {
        // Your bearer token configuration here
    });
    //Note how there is no `AddCookie` method here


services.AddAuthorization(options => {
    options.DefaultPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "Identity.Application")
        .Build();
});

Then you can simply use [Authorize] on your controllers and the app will automatically decide whether to use Bearer or Cookie authentication:

[Authorize]
[ApiController]
[Route("api/something")]
public class MyController : ControllerBase {
    // ...

And finally, you can configure the cookie properties (like sliding expiration or login path) using services.ConfigureApplicationCookie:

services.ConfigureApplicationCookie(options => {
        options.SlidingExpiration = true;
        //Return a 401 error instead of redirecting to login
        options.Events.OnRedirectToLogin = async context => {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes("Cookies: 401 unauthorized"));
        };
        //Return a 403 error instead of redirecting to login
        options.Events.OnRedirectToAccessDenied = async context => {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes("Cookies: 403 forbidden"));
        };
    });

Essentially this is all because using signInManager to log in uses the .ASpNetCore.identity.Application cookie rather than the standard cookies.

Credit goes to user Mashtani on this answer.

I was stuck for hours and hours on this going round in circles with 401 errors before I stumbled across the solution.

AvahW
  • 2,083
  • 3
  • 24
  • 30
0

Tested with Asp.net Core 2.2

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

services.AddAuthentication(options =>
    {
        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.Authority = "https://localhost:4991";
        options.RequireHttpsMetadata = false;
        // name of the API resource
        options.Audience = "api";
    });


services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddOpenIdConnect("oidc", options =>
    {
        options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.Authority = "https://localhost:4991";
        options.RequireHttpsMetadata = false;

        options.ClientId = "WebApp";
        options.ClientSecret = "secret";

        options.ResponseType = "code id_token";
        options.Scope.Add("api");
        options.SaveTokens = true;
    });

services.AddAuthorization(options =>
{   
    // Add policies for API scope claims
     options.AddPolicy(AuthorizationConsts.ReadPolicy,
        policy => policy.RequireAssertion(context =>
            context.User.HasClaim(c =>
                ((c.Type == AuthorizationConsts.ScopeClaimType && c.Value == AuthorizationConsts.ReadScope)
                || (c.Type == AuthorizationConsts.IdentityProviderClaimType))) && context.User.Identity.IsAuthenticated
        ));
    // No need to add default policy here
});


app.UseAuthentication();
app.UseCookiePolicy();

In the controller, add necessary Authorize attribute

[Authorize(AuthenticationSchemes = AuthorizationConsts.BearerOrCookiesAuthenticationScheme, Policy = AuthorizationConsts.ReadPolicy)]

Here is the helper class

public class AuthorizationConsts
{
    public const string BearerOrCookiesAuthenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme + "," + IdentityServerAuthenticationDefaults.AuthenticationScheme;
    public const string IdentityProviderClaimType = "idp";
    public const string ScopeClaimType = "scope";
    public const string ReadPolicy = "RequireReadPolicy";
    public const string ReadScope = "data:read";
}
Eason Kang
  • 6,155
  • 1
  • 7
  • 24
0

I had a scenario where I need to use Bearer or Cookie only for file download api alone. So following solution works for me.

Configure services as shown below.

services.AddAuthentication(options =>
{
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddCookie()
.AddJwtBearer(options =>
{
    options.Authority = gatewayUrl;
})
.AddOpenIdConnect(options =>
{
    // Setting default signin scheme for openidconnect makes it to force 
    // use cookies handler for signin 
    // because jwthandler doesnt have SigninAsync implemented
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.Authority = "https://youridp.com";
    options.ClientId = "yourclientid";
    options.CallbackPath = "/signin-oidc";
    options.ResponseType = OpenIdConnectResponseType.Code;
});

Then configure your controller as shown below.

[HttpGet]
[Authorize(AuthenticationSchemes = "Bearer,OpenIdConnect")]
public async Task<IActionResult> Download([FromQuery(Name = "token")] string token)
{
    ///your code goes here.
    ///My file download api will work with both bearer or automatically authenticate with cookies using OpenidConnect.
}
0

Christo Carstens, answer worked perfectly for me. Just thought I'd share an additional check that I added to his AddPolicyScheme. (see above) In my case the issue was that I had an Azure Web Service that was handling all my mobile app requests using JWT, but I also needed it to act as a gateway for Google/Apple/Facebook authentication which uses cookies. I updated my startup as recommended

.AddPolicyScheme( "smart", "Bearer or Jwt", options =>
{
    options.ForwardDefaultSelector = context =>
    {
        var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith( "Bearer " ) ?? false;
        // You could also check for the actual path here if that's your requirement:
        // eg: if (context.HttpContext.Request.Path.StartsWithSegments("/api", StringComparison.InvariantCulture))
        if ( bearerAuth )
            return JwtBearerDefaults.AuthenticationScheme;
        else
            return CookieAuthenticationDefaults.AuthenticationScheme;
    };
} )

My only problem was that if a call was made to any of my api calls which had the [Authorize] attribute set, and no "Authorization" key was in the headers, then it would use Cookie authorization and return a Not found (404) instead of Unauthorized (401). His suggestion to check for the Path worked, but I wanted to enforce JWT on any method which, in the future, may not have that path. In the end I settled for this code.

.AddPolicyScheme("CookieOrJWT", "Bearer or Jwt", options =>
                {
                    options.ForwardDefaultSelector = context =>
                    {
                        var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith("Bearer ") ?? false;
                        
                        if (bearerAuth)
                            return JwtBearerDefaults.AuthenticationScheme;
                        else
                        {
                            var ep = context.GetEndpoint();
                            var requiresAuth = ep?.Metadata?.GetMetadata<AuthorizeAttribute>();
                            return requiresAuth != null 
                                ? JwtBearerDefaults.AuthenticationScheme
                            : CookieAuthenticationDefaults.AuthenticationScheme;
                        }
                    };
                })

By checking the Endpoint metadata (only in rare cases where Authorization is not in the header), I can set JwtBearerDefaults.AuthenticationScheme for any method decorated with the [Authorize] attribute. This works even if the method is inheriting the [Authorize] attribute from it's class and does not have it explicitly set. e.g.

[ApiController]
[Route("api/[Controller]")]
[Authorize]
public class MyController : ControllerBase {
  
    [HttpGet]
    public ActionResult MyWebRequestThatRequiresAuthorization() {
       return true;
    }
}

Thanks to Christo Carstens for the solution. I was breaking my head over this. Saved me countless hours.

Sol Fried
  • 11
  • 1