2
votes

I have an IdentityServer setup with an API and a SPA written with React. The SPA is using the javascript oidc client library to authenticate with the IdentityServer and then get data from the API. The SPA and API are in the same project, the SPA is served using services.AddSpaStaticFiles and app.UseSpa so I thought I should be able to use both authentication schemes interchangeably.

The problem is I have images stored on the API side that I want the SPA client to be able to get and place within an < img > tag, with the option to click on it and open the full size image in a new window. The images have to require the user to be authenticated to access them though.

I've tried adding Cookie based authentication to the API's ConfigureServices, hoping that having the user authenticated on the SPA and then visiting the image URL on the API would work.

services.AddAuthentication(options => 
{ 
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
.AddIdentityServerAuthentication("apiAuth", options =>
{
    options.Authority = "http://localhost:5000";
    options.RequireHttpsMetadata = false;
    options.ApiName = "api";
})
.AddCookie("cookieAuth")
.AddOpenIdConnect("oidc", options =>
{
    options.Authority = "http://localhost:5000";
    options.RequireHttpsMetadata = false;
    options.ClientId = "afx_api";
    options.SaveTokens = true;
 });

And then adding [Authorize(AuthenticationSchemes = "cookieAuth")] on the Controller that'll return an image, and [Authorize(AuthenticationSchemes = "apiAuth")] on all the other API controllers.

However when I try visiting an image, like lets say http://localhost:6000/api/file/1 I get redirected here http://localhost:6001/Account/Login?ReturnUrl=%2Fapi%2Fdocuments%2Ffile%2F90404 even though I've been authenticated and the normal API calls work.

How would I go about doing this? Thanks

EDIT: More code from my setup

IdentityServer/Config.cs client config

new Client
{
    ClientId = "client",
    ClientName = "React Client",
    AllowedGrantTypes = GrantTypes.Implicit,
    AllowAccessTokensViaBrowser = true,
    RequireConsent = false,
    AccessTokenLifetime = 3600,

    RedirectUris = {
        "http://localhost:6001/callback",
        "http://localhost:6001/silent_renew.html",
    },
    PostLogoutRedirectUris =
    {
        "http://localhost:6001/",
    },
    AllowedCorsOrigins =
    {
        "http://localhost:6001",
    },

    AllowedScopes = new List<string>
    {
        IdentityServerConstants.StandardScopes.OpenId,
        IdentityServerConstants.StandardScopes.Profile,
        "api"
    },
    AlwaysIncludeUserClaimsInIdToken = true
}

ClientApp/src/userManager.js

import { createUserManager } from 'redux-oidc';

const settings = {
    client_id: 'client',
    redirect_uri: `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}/callback`,
    response_type: 'id_token token',
    scope:"openid profile api",
    authority: 'http://localhost:5000',
    silent_redirect_uri: `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}/silent_renew.html`,
    automaticSilentRenew: true,
    loadUserInfo: true,
    monitorSession: true
};

const userManager = createUserManager(settings);

export default userManager;

New startup based on Elrashid's answer:

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
services.AddAuthentication(options =>
    {
        options.DefaultScheme = "cookies";
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie("cookies",
                    options => options.ForwardDefaultSelector = ctx => ctx.Request.Path.StartsWithSegments("/api") ? "jwt" : "cookies")
    .AddJwtBearer("jwt", options =>
    {
        options.Authority = "http://localhost:5000";
        options.Audience = "api";
        options.RequireHttpsMetadata = false;
    })
    .AddOpenIdConnect("oidc", options =>
    {
        options.SignInScheme = "cookies";
        options.Authority = "http://localhost:5000";
        options.RequireHttpsMetadata = false;
        options.ClientId = "client";
        options.SaveTokens = true;

        options.ResponseType = "id_token token";
        options.GetClaimsFromUserInfoEndpoint = true;
        options.Scope.Add("api");
        options.Scope.Add("offline_access");
        options.ForwardDefaultSelector = ctx => ctx.Request.Path.StartsWithSegments("/api") ? "jwt" : "oidc";
    });

Still getting the same result, which is a redirect to /Account/Login?ReturnUrl=%2Fimages%2Ffile%2F90404 when I try to get an image from the controller. The API still works. The controller method returns a PNG and without authorization required it works.

[Authorize(AuthenticationSchemes = "cookies")]
[Route("[controller]")]
public class ImagesController : Controller
{
    ...
}
2
do you use reactjs.net for integrating asp.net and reactjs - Mohamed Elrashid
No I don't think so. I used the template that gets created when you choose to make a new project with .Net Core and React with Redux. I'm pretty sure all that does is use the Create React App template and adds the services.AddSpaStaticFiles and app.UseSpa methods to Startup. docs.microsoft.com/en-us/aspnet/core/client-side/spa/… - MichaelD

2 Answers

2
votes

using ForwardDefaultSelector

  • all Routes will use cookies

  • but Route start with /api will use jwt

    ctx.Request.Path.StartsWithSegments("/api") ? "jwt" : "cookies")
    
    ctx.Request.Path.StartsWithSegments("/api") ? "jwt" : "oidc")
    

if you have this 2 Route

- localhost/Secure/Index

- localhost/api/secure/Get

SecureController

public class SecureController : Controller
{
    [Authorize]
    public IActionResult Index()
    {
        return View();
    }
}

SecureApi

[Route("api/secure")]
[Authorize]
public class SecureApi : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        return new JsonResult(from c in User.Claims select new { c.Type, c.Value });
    }
}

Clear Jwt Security Token Handler

JwtSecurityTokenHandler
   .DefaultInboundClaimTypeMap.Clear();

use AddAuthentication to set cookies as DefaultScheme

services.AddAuthentication(options =>
        {
            // Notice the schema name is case sensitive [ cookies != Cookies ]
            options.DefaultScheme = "cookies";
            options.DefaultChallengeScheme = "oidc";
        })

Add Cookie options

    .AddCookie("cookies", options => 
     options.ForwardDefaultSelector = ctx => 
      ctx.Request.Path.StartsWithSegments("/api") ? "jwt" : "cookies")

Add Jwt Bearer

   .AddJwtBearer("jwt", options =>
    {
        options.Authority = "http://localhost:5010";
        options.Audience = "app2api";
        options.RequireHttpsMetadata = false;
    })

AddOpenIdConnect

        .AddOpenIdConnect("oidc", options =>
    {
        options.SignInScheme = "cookies";
        options.Authority = "http://localhost:5010";
        options.RequireHttpsMetadata = false;
        options.ClientId = "mvc";
        options.SaveTokens = true;

        options.ClientSecret = "secret";
        options.ResponseType = "code id_token";
        options.GetClaimsFromUserInfoEndpoint = true;
        options.Scope.Add("app2api");
        options.Scope.Add("offline_access");
        //https://github.com/leastprivilege/AspNetCoreSecuritySamples/blob/aspnetcore21/OidcAndApi/src/AspNetCoreSecurity/Startup.cs
        options.ForwardDefaultSelector = ctx => ctx.Request.Path.StartsWithSegments("/api") ? "jwt" : "oidc";
    });

full Startup.cs

 public class Startup
{


    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<CookiePolicyOptions>(options =>
        {
            // This lambda determines whether user consent for non-essential cookies is needed for a given request.
            options.CheckConsentNeeded = context => true;
            options.MinimumSameSitePolicy = SameSiteMode.None;
        });

        ////////////////////////////////
        JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
        services.AddAuthentication(options =>
        {
            // Notice the schema name is case sensitive [ cookies != Cookies ]
            options.DefaultScheme = "cookies";
            options.DefaultChallengeScheme = "oidc";
        })

        .AddCookie("cookies", options => options.ForwardDefaultSelector = ctx => ctx.Request.Path.StartsWithSegments("/api") ? "jwt" : "cookies")
        .AddJwtBearer("jwt", options =>
        {
            options.Authority = "http://localhost:5010";
            options.Audience = "app2api";
            options.RequireHttpsMetadata = false;
        })
        .AddOpenIdConnect("oidc", options =>
        {
            options.SignInScheme = "cookies";
            options.Authority = "http://localhost:5010";
            options.RequireHttpsMetadata = false;
            options.ClientId = "mvc";
            options.SaveTokens = true;

            options.ClientSecret = "secret";
            options.ResponseType = "code id_token";
            options.GetClaimsFromUserInfoEndpoint = true;
            options.Scope.Add("app2api");
            options.Scope.Add("offline_access");

            options.ForwardDefaultSelector = ctx => ctx.Request.Path.StartsWithSegments("/api") ? "jwt" : "oidc";
        });
        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
            app.UseHsts();
        }

        app.UseAuthentication();
        app.UseHttpsRedirection();
        app.UseStaticFiles();
        app.UseCookiePolicy();

        app.UseMvcWithDefaultRoute();
    }
}

in identity server use HybridAndClientCredentials

    new Client
    {
        ClientId = "mvc",
        ClientName = "MVC Client",
        AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
        ClientSecrets =
        {
            new Secret("secret".Sha256())
        },
        // where to redirect to after login
        RedirectUris = { "http://localhost:5011/signin-oidc" },
        // where to redirect to after logout
        PostLogoutRedirectUris = { "http://localhost:5011/signout-callback-oidc" },
        AllowedScopes = new List<string>
        {
            IdentityServerConstants.StandardScopes.OpenId,
            IdentityServerConstants.StandardScopes.Profile,
            "app2api"
        },
        AllowOfflineAccess = true
    }

Summary :Sample App on my Github

0
votes

@MichaelD, I was able to fix this on my project following your post with a slight modification. I scaffolded Identity via this post: https://docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity?view=aspnetcore-3.1&tabs=visual-studio#scaffold-identity-into-an-mvc-project-with-authorization. You'll need to Select the Login.cshtml file to override. When you complete this, you should get an Areas/Identity/Pages/Account/Manage/Login.cshtml.cs file in the project. Open this and go to the method OnPostAsync.

    public async Task<IActionResult> OnPostAsync(string returnUrl = null)
    {
        returnUrl = returnUrl ?? Url.Content("~/");

        if (ModelState.IsValid)
        {
            // This doesn't count login failures towards account lockout
            // To enable password failures to trigger account lockout, set lockoutOnFailure: true
            var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: true);
            if (result.Succeeded)
            {
                await AddAuthorizationCookie();
                _logger.LogInformation("User logged in.");
                return LocalRedirect(returnUrl);
            }
            if (result.RequiresTwoFactor)
            {
                return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
            }
            if (result.IsLockedOut)
            {
                _logger.LogWarning("User account locked out.");
                return RedirectToPage("./Lockout");
            }
            else
            {
                ModelState.AddModelError(string.Empty, "Invalid login attempt.");
                return Page();
            }
        }

        // If we got this far, something failed, redisplay form
        return Page();
    }

    private async Task AddAuthorizationCookie()
    {
        var user = await _userManager.FindByEmailAsync(Input.Email);
        if (user == null)
        {
            return;
        }
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, user.Email),
            new Claim("FullName", user.FullName)
        };

        var claimsIdentity = new ClaimsIdentity(
            claims, "cookies");

        var authProperties = new AuthenticationProperties
        {
            //AllowRefresh = <bool>,
            // Refreshing the authentication session should be allowed.

            //ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10),
            // The time at which the authentication ticket expires. A 
            // value set here overrides the ExpireTimeSpan option of 
            // CookieAuthenticationOptions set with AddCookie.

            //IsPersistent = true,
            // Whether the authentication session is persisted across 
            // multiple requests. When used with cookies, controls
            // whether the cookie's lifetime is absolute (matching the
            // lifetime of the authentication ticket) or session-based.

            //IssuedUtc = <DateTimeOffset>,
            // The time at which the authentication ticket was issued.

            //RedirectUri = <string>
            // The full path or absolute URI to be used as an http 
            // redirect response value.
        };

        await HttpContext.SignInAsync(
            "cookies",
            new ClaimsPrincipal(claimsIdentity),
            authProperties);
    }

The method OnPostAsync comes from Identity, but I added line "await AddAuthorizationCookie()" which allows us to add the cookie that we need to the client. Now when I access my images controller, the right cookie is present. You should also remove the cookie on logout and possibly when renewing the token. I haven't gotten to renewing the token yet.