Link to home
Start Free TrialLog in
Avatar of Kyle Abrahams, PMP
Kyle Abrahams, PMPFlag for United States of America

asked on

Response.Redirect with Headers in .Net Core

Hi All,

Trying to bring this authorization workflow together.

Essentially I have a protected service that requires a JWT bearer token.
I have an authorization service that generates a JWT bearer token.

In My Startup for the protect service:

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
         app.UseStatusCodePages(async context =>
            {
                var request = context.HttpContext.Request;
                var response = context.HttpContext.Response;
                var path = request.Path.Value ?? "";

                if ( 
                        (response.StatusCode == (int)HttpStatusCode.Unauthorized || response.StatusCode == (int)HttpStatusCode.Forbidden)
                    && !request.Headers.ContainsKey("Authorization")
                   )
                {
                    var redirect = context.HttpContext.AbsoluteURL();
                    response.Redirect(config["JWT:AuthorizationServiceRedirect"] + config["AppID"] + "?redirectTo=" + redirect);
                }
            });
}

Open in new window


Essentially what this does it redirects to the Authorization service if the response fails.  No issues no problems there.

My authorization service also generates the JWT token. Part of that controller's code is:



            string redirect = HttpContext.Request.Query["redirectTo"].ToString();

            if (!String.IsNullOrEmpty(redirect))
            {
                Response.Headers.Add("Authorization", "Bearer " + dto.user.token);
                Response.Redirect(redirect);
            }

Which brings it back to my protected service.  In theory this Response should now contain the bearer token, however the protected service not letting it thru.  Upon inspecting the headers I don't see anything there.  From my research the headers aren't passed along on the redirect, so what's best practice on getting this to work and are there any security issues with the above?
Avatar of Zvonko
Zvonko
Flag of North Macedonia image

Avatar of Kyle Abrahams, PMP

ASKER

I already have most of that up and running.  

My question is more about automatically referring to get the JWT token if it's invalid.

EG:
   I have a service that gives me the JWT.
   I have the protected service which will validate the JWT and only allow the controller to be called if it has a valid JWT.

What I'm attempting to do now is from the protected service say if the request doesn't have a valid JWT (EG: you don't have one period OR it's expired), go get a new one and then come back.

The redirect to the authentication service works.  and I can come back to where I was after validating, but the service isn't seeing the new JWT which was just got.
Can you clarify what do you mean by:
I have the protected service which will validate the JWT and only allow the controller to be called if it has a valid JWT.


Hi Chinmay,

Essentially the I have 2 different applications (micro services).  

The "Protected Service" is a service that requires authentication and authorization.  To achieve that it demands a JWT token that is signed with a private key by the authorization service.  If I try to navigate to the protected service without the token, I get a 401 (expected behavior).  I can then use postman to take a key from the authorization service and include it as part of the header (Authorization, "Bearer " +token).   Essentially I have a setup very similar to https://garywoodfine.com/asp-net-core-2-2-jwt-authentication-tutorial/.  

With that said, I'm trying to make the authentication piece happen without returning to the client.  You'll see in the link provided that it goes to the authentication service first, gets the token, back to the client, then calls the next service.  I'm trying to change that workflow and just call the 2nd service.  If it has a 401, then it should redirect the client to the authorization service, come back to the service when done, and finally serve the original request, passing what's needed to the client so that its authenticated until the next timeout happens.

I'm trying to make it more like an ADFS workflow rather than the current setup.  My issue is when I redirect I'm losing the headers.

Here's a good workflow of what I'm after:
http://www.c0denet.co.uk/2018/09/12/adfs-authentication-workflow/

Essentially rather than the client being aware of the token and maintaining that just have the services do all the heavy lifting.
Hi Kyle,

By your explanation, one thing is clear; I got confused by the name Protected Service.
Can you post your startup.cs for the services, Scrub out anything and everything that you deem sensitive.

If you don't want to post them due to some reason, I have couple of points for you to explore (Trial and Error)
1. In your Configure method, do you have?
app.UseForwardedHeaders();

Open in new window

2.Do you have some proxy or load-balancer in play? or maybe Kestrel & IIS Combo? If yes, then In your ConfigureServices method, you might have to set ForwardedHeadersOptions (https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-3.1)

Regards,
Chinmay.
Essentially rather than the client being aware of the token and maintaining that just have the services do all the heavy lifting.
I had a similar implementation couple of months back and I swore that I will never write my own implementation going forward. :P
I'm getting ready to re-write our whole ERP.  So if I can code it once I'll take the headache and save myself the repeat code in the other applications.  I'm only in the local IIS express, haven't deployed anything.  Again still a work in progress, right now I'm just pulling in the key from the file system.  Still have some hardening to do but that's easier in my mind.

I haven't tried the forwarded headers, going to try that now but wanted to get this back to you.



"Protected Service Startup"
public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            config = configuration;
        }

        public IConfiguration config { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddBusiness(config);

            RsaSecurityKey publicKey;

            using (TextReader stream = System.IO.File.OpenText(@"C:\Download\RSAPublicKey.txt"))
            {
                var reader = new PemReader(stream);

                AsymmetricKeyParameter pk = (AsymmetricKeyParameter)reader.ReadObject();
                RSAParameters rsaParams = DotNetUtilities.ToRSAParameters((RsaKeyParameters)pk);

                publicKey = new RsaSecurityKey(rsaParams);

            }


            services.AddAuthentication(auth =>
            {


                auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;

            })

    .AddJwtBearer(token =>
    {
        token.RequireHttpsMetadata = false;
        token.SaveToken = true;
        token.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = publicKey,
            ValidateIssuer = true,
            ValidIssuer = config["JWT:Issuer"],
            ValidateAudience = true,
            ValidAudience = config["JWT:Audience"],
            RequireExpirationTime = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(int.Parse(config["JWT:ClockSkewMin"]))
        };
    });

            services.AddAuthorization();


            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)
        {
         app.UseStatusCodePages(async context =>
            {
                var request = context.HttpContext.Request;
                var response = context.HttpContext.Response;
                var path = request.Path.Value ?? "";

                if ( 
                        (response.StatusCode == (int)HttpStatusCode.Unauthorized || response.StatusCode == (int)HttpStatusCode.Forbidden)
                    && !request.Headers.ContainsKey("Authorization")
                   )
                {
                    var redirect = context.HttpContext.AbsoluteURL();
                    response.Redirect(config["JWT:AuthorizationServiceRedirect"] + config["AppID"] + "?redirectTo=" + redirect);
                }
            });
       

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();

            app.UseRouting();

            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }


    }

Open in new window


Authorization Service Startup - nothing real here but including it.  This is windows authorization protected.
  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.AddBusiness(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();
            });
        }
    }

Open in new window


Authorization Service Controller:
  [HttpGet("GetClaims/{Appid}")]
        public async Task<ActionResult<UserClaimsDto>> GetClaims(int? AppId)
        {
            string[] parts = HttpContext.User.Identity.Name.Split(new char[] { '\\' });

            string user = parts[parts.Length - 1];

            var dto = await Mediator.Send(new GetUserClaimsForLoginQuery() { login = user, AppId = AppId });

            string redirect = HttpContext.Request.Query["redirectTo"].ToString();


           //attempts at adding the authorization header.
            if (!String.IsNullOrEmpty(redirect))
            {
                Response.Headers.Add("Authorization", "Bearer " + dto.user.token);
                Response.Headers.Add("Location", redirect);

              //Response.Redirect(redirect)
            }

            return dto.user;
                        
        }

Open in new window


GetUserClaimsQuery:

using AutoMapper;
using AutoMapper.QueryableExtensions;
using Company.Core.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using ThirdParty.BouncyCastle.OpenSsl;

 public class GetUserClaimsForLoginQuery : IRequest<UserClaimsVm>
    {
        // any input parameters go here.

        public string login { get; set; }

        public int? AppId { get; set; }
        public class GetUserClaimsForLoginQueryHandler : IRequestHandler<GetUserClaimsForLoginQuery, UserClaimsVm>
        {
            private readonly IAppSettingsDBContext _context;
            private readonly IMapper _mapper;

            public GetUserClaimsForLoginQueryHandler(IAppSettingsDBContext context, IMapper mapper)
            {
                _context = context;
                _mapper = mapper;
            }

            public async Task<UserClaimsVm> Handle(GetUserClaimsForLoginQuery request, CancellationToken cancellationToken)
            {
                var user = await _context.Users.Include(u => u.UserGroups).ThenInclude(ug => ug.Group)
                         .Where(u => u.Username == request.login)
                        .SingleOrDefaultAsync()
                        ;

                string groups = "";

                foreach (var ug in user.UserGroups)
                {
                    groups += ug.Group.GroupName;
                }


                string PrivilegeApp = "";
                string Privileges = "";

                if (request.AppId != null)
                {

                    // IMPORTANT, DO NOT INJECT PARAMS DIRECTLY INTO THE RAW STRING.  THIS WAY IS SAFE AS IT CONVERTS TO DB PARAMS.
                    var privs = await _context.GetDistinctUserApplicationPrivileges.FromSqlRaw("EXECUTE dbo.GetDistinctUserApplicationPrivileges {0}, {1}", user.UserId, request.AppId).ToListAsync(cancellationToken);
                   
                    foreach (var priv in privs)
                    {
                        Privileges += priv.PrivilegeName + ",";
                    }

                    Privileges = Privileges.Trim(',');

                    if (Privileges.Length > 0)
                        PrivilegeApp = privs.First().ApplicationName;

                }

                var now = DateTime.UtcNow;

            // generated from 
            // https://travistidwell.com/jsencrypt/demo/

                using (TextReader stream = System.IO.File.OpenText(@"C:\Download\RSAPrivateKey.txt"))
                {
                    var reader = new PemReader(stream);

                    var key = new RsaSecurityKey(reader.ReadPrivatekey());

                    var claims = new List<Claim>()
                    {
                        new Claim(ClaimTypes.Name, user.FirstName),
                        new Claim(ClaimTypes.GivenName, user.LastName),
                        new Claim("UserGroups", groups),
                        new Claim("UserId", user.UserId.ToString()),
                        new Claim(ClaimTypes.WindowsAccountName, user.Username)
                
                    };

                    // if this is populated, it's for a user and application.
                    if (!String.IsNullOrEmpty(PrivilegeApp))
                        claims.Add(new Claim(PrivilegeApp, Privileges));

                    var token = new SecurityTokenDescriptor()
                    {
                        Expires = DateTime.UtcNow.AddMinutes(20),
                        Issuer = "jwt.company.com",
                        Audience = "services.company.com",
                        Subject = new ClaimsIdentity(claims),
                        Claims = claims.ToDictionary(group => group.Type, group => (object)group.Value),
                        IssuedAt = now,
                        SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256)
                    };

                    var tokenHandler = new JwtSecurityTokenHandler();

                    UserClaimsDto usr = new UserClaimsDto();


                    usr.token = tokenHandler.CreateEncodedJwt(token);

                    var vm = new UserClaimsVm
                    {
                        user = usr
                    };

                    return vm;

                }



            }


        }
    }

Open in new window


Also some of the comments are reminders for myself and my team so that when they read this they understand what is going on.
Just an update but it appears that the app.UseForwardedHeaders(); didn't work.  I only think that will work for a proxy / load balancer.  These are just straight redirects.

Client hits protected service
Protected Service tells client go to authentication service
Client hits authentication service
Authentication service injects token into header after success, tells client to go back to Protected Service
Protected Service -> 401, doesn't see the injected header.

Everything I'm reading says the client should handle this, but ADFS I know does this.  Would writing a cookie with the token be better?

Also my clients are going to be WPF applications, does that make a difference with this implementation?

Thanks for the suggestions so far.
Hi Kyle,

I was developing a multi-component app (Product) for a client and I did succeed but my authentication was such that any client (3 Web Apps and one mobile app) would go to the API and if they did not have the token, I would redirect them to a login Page and once they login each request would carry token for the subsequent calls (until the token expires).
All in all, there was a lot of R&D involved from my end and I do not think I was satisfied with the end result.

About the cookies, I had to drop the idea of using cookies as it did not serve my purpose. And created a bit of a mess for the mobile app.
I think you should be fine with the cookies unless you have some restricting challenges.

About the WPF app, it won't be a problem. Claims based authentication has earned its place because of the flexibility it provides. WPF app, will be more or less same. User will enter credentials, your api will authenticate, issue a token in response, your WPF app will keep it and use it as long as it is allowed.  

What I learnt that it was not necessarily the best investment of my time. I should have focused on the functionality part. And that is why (After hearing you are trying re-building an ERP), I am suggesting, if it is not an absolute must for you to have a self-written code, you can give a try to: https://identityserver.io/, you will have to build a little Admin UI (You can check their github, a sample UI is there) but I am sure you can manage that.

Regards,
Chinmay.
Yeah - from a video I'm watching it looks like identity server may work.  Time for some R&D - will take me some time to re-adjust and learn about this.  If I end up going that route your comment will obviously be the solution.  Would really prefer my own as they are essentially doing what I'm talking about but why re-invent the wheel.

Thanks for the referral, stay tuned for updates.
 doing what I'm talking about but why re-invent the wheel.
Exactly. I am done re-inventing the wheel or at least try to not to. I do not have the energy / passion I used to have. Maybe time to look for something else.

So from this video at the 57 minute mark it looks like they're doing using an httpContext.Challenge to do the redirects.
https://www.youtube.com/watch?v=nyUD-CeBSiE

Essentially from what I'm seeing the IdentityServer is it's own STS which writes the claims, and I already have that portion.  Question is how do I do the challenge properly in the middleware.



Still evaluating identityserver but the challenge caught my attention versus just the response.redirects.
Update:

Not sure why I didn't think of this before, but a lot closer.

Rather than doing the whole redirect thing, just create a web client that will invoke the other service for me.
This works seamlessly and I now have the token.

Question is how do I get the *current* request to respect the new token?

Look for the res > 1 if statement.  I have the token, now just need to have the protected service recognize it.

   app.UseStatusCodePages(async context =>
            {
                var request = context.HttpContext.Request;
                var response = context.HttpContext.Response;
                var path = request.Path.Value ?? "";

                if ( 
                        (response.StatusCode == (int)HttpStatusCode.Unauthorized || response.StatusCode == (int)HttpStatusCode.Forbidden)
                    && !request.Headers.ContainsKey("Authorization")
                   )
                {
                    var redirect = context.HttpContext.AbsoluteURL();

                    WebClient c = new WebClient
                    {
                        UseDefaultCredentials = true
                    };

                   // result is "token:<SignedJWT>"
                    string[] res = c.DownloadString(config["JWT:AuthorizationServiceRedirect"] + config["AppID"]).Split(":");
                    string token = "";

                    if (res.Length > 1)
                    {
                        token = res[1];
                        context.HttpContext.Request.Headers.Add("Authorization", "Bearer " + token);
****                        //this still is returning 401 but I have the new token  *****
                       //context.HttpContext.Response.RedirectToAbsoluteUrl(redirect);
                    }

                }
            });

Open in new window

ASKER CERTIFIED SOLUTION
Avatar of Kyle Abrahams, PMP
Kyle Abrahams, PMP
Flag of United States of America image

Link to home
membership
This solution is only available to members.
To access this solution, you must be a member of Experts Exchange.
Start Free Trial
As most in our lives, we are making it more complicated by Redirecting  ;-)

Thank you for sharing.


For anyone else coming across this solution DID work when on localhost and I was running everything in my own user context.

However it's been a while since I've done the web thing and forgot completely about publishing to IIS and all that goes along with that.  I'm still looking for a complete solution to redirect (or make the request), get the token, and then come back using HTTP headers (or somehow make the request to the STS to get the token passing the credentials needed).

I'm caught in a catch 22.

I have my STS using windows authentication, so I need to pass those along to the STS.
However my APIs are using anonymous access with JWT tokens.
If I switch the APIs to Windows Authentication the Authorize respects the fact that the user is logged in and doesn't forward the request to the STS.  
If I keep it anonymous I can't pass the windows auth to the STS.

Looking for the sweet spot of Windows Auth and BEARER, but it looks like that hasn't been done as of yet.  

Cookies are an option and saving the token that way, but I feel like this should be possible somehow.  

Out of necessity and that this is a nice to have I'm going to push the responsibility of the bearer to the client.  Which I know is the normal workflow but this should work in my mind.

If I ever find anything I promise I'll be back.  Feel free to leave me entries or write me directly.

Thanks,

Kyle