Using IdentityServer4 Auth in ServiceStack

mvcidentityserver .NET 6.0 MVC Website integrated with IdentityServer4 Auth and ServiceStack:

The mvcidentityserver builds upon Identity Server's OpenID Connect Hybrid Flow Authentication and API Access Tokens Quickstart project to include integration with ServiceStack and additional OAuth providers.

The home page has also been customized to contain the same functionality as the other 2 templates with some additional features to validate against custom OAuth App scopes and Delegation Auth Pages showing how to make Authenticated API requests to our remote microservices from within MVC Controllers.

In contrast to integrating Authentication into our App directly, mvcidentityserver configures a central remote IdentityServer instance with the Auth Features and OAuth providers we want available to our Apps.

Then when non Authenticated users go to a protected resource they're redirected to the Sign In page on IdentityServer:

Users can sign in using the same Credentials or external OAuth Providers but are presented with an additional consent screen to grant the App permission to access their User profile information and access to custom features the App needs:

Once granted the Auth information is captured in a stateless IdentityServer Token, stored in a cookie and redirected back to the App.

Physical Project Structure

mvcidentityserver is pre-configured with 3 Host projects:

IdentityServer

The IdentityServer instance is configured in Startup.cs which contains all external OAuth providers we want to allow Sign Ins from, the OpenId Connect Endpoint which allows Sign Ins from another external IdentityServer on demo.identityserver.io and all its pre-configured Users, Identity Resources, API Resources and Clients defined in Config.cs.

App

The App's Startup.cs consists of configuring the OpenId Connect Endpoint to our Central IdentityServer containing additional customizations to control what Claims the Authenticated ClaimsPrincipal will have:

services.AddAuthentication(options =>
    {
        options.DefaultScheme = "Cookies";
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie("Cookies")
    .AddOpenIdConnect("oidc", options =>
    {
        options.SignInScheme = "Cookies";

        options.Authority = "https://localhost:5000";
        options.RequireHttpsMetadata = false;

        options.ClientId = "mvc";
        options.ClientSecret = "secret";
        options.ResponseType = "code id_token";

        options.SaveTokens = true;
        options.GetClaimsFromUserInfoEndpoint = true;

        options.Scope.Add("api1");
        options.Scope.Add("offline_access");
        
        options.ClaimActions.MapJsonKey("website", "website");
        options.ClaimActions.MapJsonKey("role", "role");
        options.ClaimActions.Add(new AdminRolesClaimAction("Manager", "Employee"));
        
        options.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = "name",
            RoleClaimType = "role"
        };

        options.Events = new OpenIdConnectEvents {
            OnRemoteFailure = CustomHandlers.HandleCancelAction,
            OnTokenResponseReceived = CustomHandlers.CopyAllowedScopesToUserClaims,                        
        };
    });

ASP.NET Core Identity Auth Adapter

The only customization needed in ServiceStack is to specify the different custom name being used for RoleClaimType:

Plugins.Add(new AuthFeature(() => new CustomUserSession(), 
    new IAuthProvider[] {
        // Adapter to enable ASP.NET Core Identity Auth in ServiceStack
        new NetCoreIdentityAuthProvider(AppSettings) {
            RoleClaimType = "role"
        }, 
    }));

The MapJsonKey contains a whitelist of properties in Identity Server's Token we want propagated to Claims. The AdminRolesClaimAction is a custom ClaimAction we can use to add additional AdminRoles to users with the RoleNames.Admin role:

/// <summary>
/// Use this class to assign additional roles to Admin Users
/// </summary>
public class AdminRolesClaimAction : ClaimAction
{
    string[] AdminRoles { get; }
    public AdminRolesClaimAction(params string[] adminRoles) : base("role", null) => AdminRoles = adminRoles;

    public override void Run(JObject userData, ClaimsIdentity identity, string issuer)
    {
        if (!HasAdminRole(userData)) return;
        foreach (var role in AdminRoles)
        {
            identity.AddClaim(new Claim("role", role));
        }
    }

    private bool HasAdminRole(JObject userData)
    {
        var jtoken = userData?[this.ClaimType];
        if (jtoken is JValue)
        {
            if (jtoken?.ToString() == RoleNames.Admin)
                return true;
        }
        else if (jtoken is JArray)
        {
            foreach (var obj in jtoken)
                if (obj?.ToString() == RoleNames.Admin)
                    return true;
        }
        return false;
    }
}

The OpenIdConnectEvents lets us intercept the original IdentityServer token so we can extract the custom OAuth scopes the User has granted the App and add them to scope Claims:

public static class CustomHandlers
{
    /// <summary>
    /// Use this handler to copy requested Scopes to User Claims so they can be validated using a Policy  
    /// </summary>
    public static Task CopyAllowedScopesToUserClaims(TokenResponseReceivedContext context)
    {
        var scopes = context.ProtocolMessage.Scope?.Split(' ');
        if (scopes != null && context.Principal.Identity is ClaimsIdentity identity)
        {
            foreach (var scope in scopes)
            {
                identity.AddClaim(new Claim("scope", scope));
            }
        }
        return Task.CompletedTask;
    }

    public static Task HandleCancelAction(RemoteFailureContext context)
    {
        context.Response.Redirect("/");
        context.HandleResponse();
        return Task.CompletedTask;
    }
}

Now that our populated claims contains the granted OAuth scopes we can validate against it in ServiceStack Services using the new [RequiredClaim] attribute:

[RequiredClaim("scope", "profile")]
public object Any(RequiresScope request)
{
    return new RequiresScopeResponse { Result = $"Hello, {request.Name}!" };
}

In MVC we need create a custom Auth Policy:

services.AddAuthorization(options => {
    options.AddPolicy("ProfileScope", policy =>
        policy.RequireClaim("scope", "profile"));
});

That can then be used in our MVC Controllers using the [Authorize] attribute, referencing our custom policy:

[Authorize(Policy = "ProfileScope")]
public async Task<IActionResult> RequiresScope()
{
    var accessToken = await HttpContext.GetTokenAsync("access_token");

    return View();
}

Delegated Auth Pages

mvcidentityserver also contains examples showing how to make Authenticated API Requests to a remote Web API Service using HttpClient:

public async Task<IActionResult> CallWebApi()
{
    var accessToken = await HttpContext.GetTokenAsync("access_token");

    var client = new HttpClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    var content = await client.GetStringAsync("https://localhost:5001/webapi-identity");

    ViewBag.Json = JArray.Parse(content).ToString();
    return View("json");
}

The same HttpClient request to call an Authenticated ServiceStack Service:

public async Task<IActionResult> CallServiceStack()
{
    var accessToken = await HttpContext.GetTokenAsync("access_token");

    var client = new HttpClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(MimeTypes.Json)); 
    var json = await client.GetStringAsync("https://localhost:5001/servicestack-identity");

    ViewBag.Json = json.IndentJson();
    return View("json");
}

We need the additional Accept JSON HTTP header to tell ServiceStack which of the registered Content Types we want to receive the response in.

Alternatively we can make Authenticated Requests using the more typed and terse C#/.NET Service Client:

public async Task<IActionResult> CallServiceClient()
{
    var accessToken = await HttpContext.GetTokenAsync("access_token");

    var client = new JsonServiceClient("https://localhost:5001/") {
        BearerToken = accessToken
    };
    var response = await client.GetAsync(new GetIdentity());

    ViewBag.Json = response.ToJson().IndentJson();
    return View("json");
}

API

The API's Startup.cs is configured to accept Bearer Tokens issued by our central IdentityServer:

services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options => {
        options.Authority = "https://localhost:5000";
        options.RequireHttpsMetadata = false;

        options.Audience = "api1";
    });

Nothing special is needed for ServiceStack here other than registering the Identity Auth Provider adapter:

Plugins.Add(new AuthFeature(() => new AuthUserSession(), 
    new IAuthProvider[] {
        new NetCoreIdentityAuthProvider(AppSettings), 
    }));

The GetIdentity ServiceStack Service then returns the populated AuthUserSession and all claims contained in the Bearer Token:

[Route("/servicestack-identity")]
public class GetIdentity : IReturn<GetIdentityResponse> { }

public class GetIdentityResponse
{
    public List<Property> Claims { get; set; }
    public AuthUserSession Session { get; set; }
}

[Authenticate]
public class IdentityService : Service
{
    public object Any(GetIdentity request)
    {
        return new GetIdentityResponse {
            Claims = Request.GetClaims().Map(x => new Property { Name = x.Type, Value = x.Value }),
            Session = SessionAs<AuthUserSession>(),
        };
    }
}