2

I'm creating a Single-Sign-on server using IdentiyServer4. I've looked at their QuickStarts showing how to integrate MS Core Identity with ASP.NET Core 3.1 apps. But there's no examples showing whether ASP.NET roles are natively supported in MVC controllers. A few experiments seemed to indicate that they aren't. But when I discovered that role data can be returned in the Access Token, I wrote my own action filter that authorises users.

However, looking at the documentation for IdentityServer3, they do briefly show roles being used in MVC controllers. So now I'm completely confused. But apart from that, there's no documentation that I can find, and the only mention online I could find about roles with IdentityServer were about a different issue - using roles to control access to remote APIs.

My filter isn't working that well, and I'm worried it's the wrong approach and unnecessary. Can anyone either enlighten me, or point me to any resources that would help.

  • When the MVC host _IS_ the Identity Server, then the normal ASP.Net Roles and Authorization attributes will be obeyed. It's a bit more effort to extend similar security to a client API, so is your IdentityServer and MVC application the _same_ project? – Chris Schaller Sep 09 '21 at 00:16
  • No, I'm using Code flow to authorise an MVC client. The client redirects to the IdentityServer to authenticate, then redirects back. So the usual [Authorize] declarations in the client controllers trigger the client authentication, but once you're returned from the server roles aren't supported as far as I can see. So you can't use [Authorize(Roles = "Administrator")] in a remote client. – drunkenwagoner Sep 09 '21 at 03:01
  • It can be done, but you need to write an implementation that resolves the credentials back into the MVC identity. I've done it in ASP, but not core. There should be guidance on this somewhere... – Chris Schaller Sep 09 '21 at 04:09

2 Answers2

0

One gotcha, is that you need to configure and tell ASP.NET Core what the name of the roles claim is in the incoming token.

Out of the box IdentityServer and Microsoft does not agree on the name of the roles claim.

So, you need to set the RoleClaimType.

   .AddOpenIdConnect(options =>
   {
       // other options...
       options.TokenValidationParameters = new TokenValidationParameters
       {
         NameClaimType = "email", 
         RoleClaimType = "role"
       };
   });

To complement this answer, I wrote a blog post that goes into more detail about this topic: Debugging OpenID Connect claim problems in ASP.NET Core

Tore Nestenius
  • 16,431
  • 5
  • 30
  • 40
0

I hope these codes will be useful for you.

I added ASP.NET Core Identity in the IdentityServer project.

Statup.cs in API Client

public class Startup
{
    public IConfiguration Configuration { get; }
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
        services.AddControllers();
        services.AddAuthentication("Bearer")
                .AddJwtBearer("Bearer", options =>
                {
                    options.Authority = "https://localhost:5001";
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateAudience = false
                    };
                });
        services.AddAuthorization(options =>
        {
            options.AddPolicy("ApiScope", policy =>
            {
                policy.RequireAuthenticatedUser();
                policy.RequireClaim("scope", "api1");
            });
        });
    }
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        app.UseHttpsRedirection();
        app.UseRouting();
        app.UseAuthentication();
        app.UseAuthorization();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers()
                      .RequireAuthorization("ApiScope");
        });
    }
}

Startup.cs in MVC Client

public class Startup
{
  public IConfiguration Configuration { get; }
  public Startup(IConfiguration configuration)
  {
    Configuration = configuration;
  }
  public void ConfigureServices(IServiceCollection services)
  {
    services.AddControllersWithViews();
    JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
    services.AddAuthentication(options =>
    {
      options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
      options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
    {
      options.Authority = "https://localhost:5001";
      options.ClientId = "mvc";
      options.ClientSecret = "secret";
      options.ResponseType = "code id_token";
      options.Scope.Add("email");
      options.Scope.Add("roles");
      options.ClaimActions.DeleteClaim("sid");
      options.ClaimActions.DeleteClaim("idp");
      options.ClaimActions.DeleteClaim("s_hash");
      options.ClaimActions.DeleteClaim("auth_time");
      options.ClaimActions.MapJsonKey("role", "role");
      options.Scope.Add("api1");
      options.SaveTokens = true;
      options.GetClaimsFromUserInfoEndpoint = true;
      options.TokenValidationParameters = new TokenValidationParameters
      {
        NameClaimType = "name",
        RoleClaimType = "role"
      };
    });
    services.AddTransient<AuthenticationDelegatingHandler>();
    services.AddHttpClient("ApplicationAPI", client =>
    {
      client.BaseAddress = new Uri("https://localhost:5002/");
      client.DefaultRequestHeaders.Clear();
      client.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/json");
    }).AddHttpMessageHandler<AuthenticationDelegatingHandler>();

    services.AddHttpClient("ApplicationIdentityServer", client =>
    {
      client.BaseAddress = new Uri("https://localhost:5001/");
      client.DefaultRequestHeaders.Clear();
      client.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/json");
    });
    services.AddHttpContextAccessor();
  }
  public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
  {
    if (env.IsDevelopment())
    {
      app.UseDeveloperExceptionPage();
    }
    else
    {
      app.UseExceptionHandler("/Home/Error");
      app.UseHsts();
    }
    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
      endpoints.MapControllerRoute(
                  name: "default",
                  pattern: "{area=Admin}/{controller=Home}/{action=Index}/{id?}");
    });
  }
}

AuthenticationDelegatingHandler in MVC Application

To prevent getting token again.

public class AuthenticationDelegatingHandler : DelegatingHandler
{
  private readonly IHttpContextAccessor _httpContextAccessor;

  public AuthenticationDelegatingHandler(IHttpContextAccessor httpContextAccessor)
  {
    _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
  }

  protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
  {
    var accessToken = await _httpContextAccessor.HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);

    if (!string.IsNullOrWhiteSpace(accessToken))
    {
      request.SetBearerToken(accessToken);
    }

    return await base.SendAsync(request, cancellationToken);
  }
}

Config.cs in IdentityServer

public static class Config
{
  public static IEnumerable<IdentityResource> IdentityResources =>
      new List<IdentityResource>
      {
        new IdentityResources.OpenId(),
        new IdentityResources.Profile(),
        new IdentityResources.Email(),
        new IdentityResource("roles", "Your role(s)", new List<string>() { "role" })
      };
  public static IEnumerable<ApiScope> ApiScopes =>
      new List<ApiScope>
      {
        new ApiScope("api1", "My API")
      };

  public static IEnumerable<Client> Clients =>
    new List<Client>
    {
      new Client
      {
          ClientId = "client",
          ClientSecrets = { new Secret("secret".Sha256()) },
          AllowedGrantTypes = GrantTypes.ClientCredentials,
          AllowedScopes = { "api1" }
      },
      new Client
      {
          ClientId = "mvc",
          ClientName = "Application Web",
          AllowedGrantTypes = GrantTypes.Hybrid,
          ClientSecrets = { new Secret("secret".Sha256()) },
          RequirePkce = false,
          AllowRememberConsent = false,
          RedirectUris = { "https://localhost:5003/signin-oidc" },
          PostLogoutRedirectUris = { "https://localhost:5003/signout-callback-oidc" },

          AllowedScopes = new List<string>
          {
              IdentityServerConstants.StandardScopes.OpenId,
              IdentityServerConstants.StandardScopes.Profile,
              IdentityServerConstants.StandardScopes.Email,
              "api1",
              "roles"
          }
      }
    };
}

Startup.cs in IdentityServer

public class Startup
{
  public IWebHostEnvironment Environment { get; }
  public IConfiguration Configuration { get; }
  public Startup(IWebHostEnvironment environment, IConfiguration configuration)
  {
    Environment = environment;
    Configuration = configuration;
  }
  public void ConfigureServices(IServiceCollection services)
  {
    services.AddControllersWithViews();
    services.AddRazorPages()
        .AddRazorPagesOptions(options =>
        {
          options.Conventions.AuthorizeAreaFolder("Identity", "/Account/Manage");
        });
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
    {
      options.SignIn.RequireConfirmedEmail = true;
    })
        .AddEntityFrameworkStores<ApplicationDbContext>()
        .AddDefaultTokenProviders();

    var builder = services.AddIdentityServer(options =>
    {
      options.Events.RaiseErrorEvents = true;
      options.Events.RaiseInformationEvents = true;
      options.Events.RaiseFailureEvents = true;
      options.Events.RaiseSuccessEvents = true;
      options.EmitStaticAudienceClaim = true;
      options.UserInteraction.LoginUrl = "/Account/Login";
      options.UserInteraction.LogoutUrl = "/Account/Logout";
      options.Authentication = new AuthenticationOptions()
      {
        CookieLifetime = TimeSpan.FromHours(10),
                CookieSlidingExpiration = true
      };
    })
        .AddInMemoryIdentityResources(Config.IdentityResources)
        .AddInMemoryApiScopes(Config.ApiScopes)
        .AddInMemoryClients(Config.Clients)
        .AddAspNetIdentity<ApplicationUser>();

    if (Environment.IsDevelopment())
    {
      builder.AddDeveloperSigningCredential();
    }
    services.AddAuthentication()
        .AddGoogle(options =>
        {
          options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
          options.ClientId = "copy client ID from Google here";
          options.ClientSecret = "copy client secret from Google here";
        });

    services.AddTransient<IEmailSender, EmailSender>();
  }
  public void Configure(IApplicationBuilder app)
  {
    if (Environment.IsDevelopment())
    {
      app.UseDeveloperExceptionPage();
    }
    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseRouting();
    app.UseIdentityServer();
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
      endpoints.MapControllerRoute(
                  name: "default",
                  pattern: "{controller=Home}/{action=Index}/{id?}");
      endpoints.MapRazorPages();
    });
  }
}
Mahmood
  • 120
  • 2
  • 9