Building and securing an ASP.NET Core API with a hosted Vue.js UI

This article shows how Vue.js can be used together with ASP.NET Core 3 in a single project. The Vue.js application is built using the Vue.js CLI and built to the wwwroot of the ASP.NET Core application. The ASP.NET Core application is used to implement the APIs consumed by the Vue.js UI. The application is secured using a separate secure token server, implemented using IdentityServer4 hosted in an ASP.NET Core 3 application.

Code: https://github.com/damienbod/AspNetCoreMvcVueJs

Other blogs in this series

Securing a Vue.js app using OpenID Connect Code Flow with PKCE and IdentityServer4

History

2020-09-12 Update to .NET Core 3.1.8, latest STS

Creating the ASP.NET Core application

The API was created using an ASP.NET Core 3 template for APIs. Json.net was added using the ASP.NET Core documentation. This was then added to the AddControllers method. An SQLite database is used to persist the different API calls, when an API call changes the state of something.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using AspNetCoreMvcVueJs.Model;
using Microsoft.EntityFrameworkCore;
using IdentityServer4.AccessTokenValidation;
using Microsoft.IdentityModel.Logging;
using AspNetCoreMvcVueJs.Repositories;

namespace AspNetCoreMvcVueJs
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            var connection = Configuration.GetConnectionString("DefaultConnection");

            services.Configure<AuthConfiguration>(Configuration.GetSection("AuthConfiguration"));
            services.Configure<AuthSecretsConfiguration>(Configuration.GetSection("AuthSecretsConfiguration"));
            services.AddScoped<IDataEventRecordRepository, DataEventRecordRepository>();

            var authConfiguration = Configuration.GetSection("AuthConfiguration");
            var authSecretsConfiguration = Configuration.GetSection("AuthSecretsConfiguration");
            var stsServerIdentityUrl = authConfiguration["StsServerIdentityUrl"];

            services.AddDbContext<DataEventRecordContext>(options =>
                options.UseSqlite(connection)
            );

            services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
                .AddIdentityServerAuthentication(options =>
                {
                    options.Authority = $"{authConfiguration["StsServerIdentityUrl"]}/";
                    options.ApiName = "DataEventRecordsApi"; //$"{authConfiguration["StsServerIdentityUrl"]}/resources";
                    options.ApiSecret = authSecretsConfiguration["ApiSecret"];
                    options.NameClaimType = "email";
                });

            services.AddAuthorization(options =>
            {
                options.AddPolicy("dataEventRecordsAdmin", policyAdmin =>
                {
                    policyAdmin.RequireClaim("role", "dataEventRecords.admin");
                });
                options.AddPolicy("dataEventRecordsUser", policyUser =>
                {
                    policyUser.RequireClaim("role", "dataEventRecords.user");
                });
                options.AddPolicy("dataEventRecords", policyUser =>
                {
                    policyUser.RequireClaim("scope", "dataEventRecords");
                });
            });

            services.AddControllers()
                .AddNewtonsoftJson();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                IdentityModelEventSource.ShowPII = true;
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                app.UseHsts();
            }

            //Registered before static files to always set header
            app.UseXContentTypeOptions();
            app.UseReferrerPolicy(opts => opts.NoReferrer());
            app.UseCsp(opts => opts
                .BlockAllMixedContent()
                .ScriptSources(s => s.Self())
                .ScriptSources(s => s.UnsafeEval())
                .ScriptSources(s => s.UnsafeInline())
                .StyleSources(s => s.UnsafeInline())
                .StyleSources(s => s.Self())
            );

            JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

            app.UseDefaultFiles();
            app.UseStaticFiles();

            //Registered after static files, to set headers for dynamic content.
            //app.UseXfo(xfo => xfo.Deny());
            app.UseRedirectValidation(t => t.AllowSameHostRedirectsToHttps(44348)); 
            app.UseXXssProtection(options => options.EnabledWithBlockMode());

            app.UseRouting();

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

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


Creating the Vue.js application

The Vue.js project was created inside the ASP.NET Core application. A new folder was created, vuejs and inside this folder, the Vue.js CLI was used to create a new project. Axios and the oidc-client npm packages were then added.

{
  "name": "vue-js-oidc-client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve --https --port 44357",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "build-production": "vue-cli-service build --mode production",
    "build-watch": "vue-cli-service build --watch"
  },
  "dependencies": {
    "axios": "^0.19.0",
    "core-js": "^3.6.5",
    "oidc-client": "^1.9.1",
    "vue": "^2.6.10",
    "vue-class-component": "^7.1.0",
    "vue-property-decorator": "^8.2.2",
    "vue-router": "^3.1.3"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "^4.5.6",
    "@vue/cli-plugin-typescript": "^4.5.6",
    "@vue/cli-service": "^4.5.6",
    "typescript": "~3.9.3",
    "vue-template-compiler": "^2.6.10"
  }
}

The build of the Vue.js CLI project needs to build to the wwwroot of the project. The vue.config.js file was changed to implement this. The outputDir was set as required.

const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    outputDir: '../wwwroot',
    configureWebpack: {
      plugins: [
        new CopyWebpackPlugin([
            { from: 'node_modules/oidc-client/dist/oidc-client.min.js', to: 'js' }
        ])
      ]
    }
  }

Running the application

To run the applications, scripts were added to the project.json npm file. The build-watch script watches for changes and rebuilds the UI project with each change.

npm run build-watch

The build command can be used for the CI build.

npm run build

Here’s an example of how an appveyor.yml could be used to implement a build for an appveyor CI.

image: Visual Studio 2019
init:
  - git config --global core.autocrlf true
environment:
  nodejs_version: "12"
install:
  - ECHO %APPVEYOR_BUILD_WORKER_IMAGE%
  - ps: Install-Product node $env:nodejs_version
  - cmd: choco install dotnetcore-sdk --pre
  - dotnet --version
  - dotnet restore
  - choco install googlechrome
build_script:
  - npm -g install npm@latest
  - cd AspNetCoreMvcVueJs/vuejs
  - npm install
  - npm run build
  - cd ../..
  - dotnet build
before_build:
  - appveyor-retry dotnet restore -v Minimal

Securing the ASP.NET Core API

The API is secured using introspection. A reference token is used to access the API and the AddIdentityServerAuthentication extension method from the IdentityServer4.AccessTokenValidation nuget package is used to implement the security. The parameters must match the configuration from the OIDC server, in this case an IdentityServer4 app. The authorization is then implemented using the claims defined for the access token, which the reference token is used for.

public void ConfigureServices(IServiceCollection services)
{
	var connection = Configuration.GetConnectionString("DefaultConnection");

	services.AddDbContext<DataEventRecordContext>(options =>
		options.UseSqlite(connection)
	);
	services.AddScoped<IDataEventRecordRepository, DataEventRecordRepository>();

	services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
	  .AddIdentityServerAuthentication(options =>
	  {
		  options.Authority = "https://localhost:44348/";
		  options.ApiName = "dataEventRecords";
		  options.ApiSecret = "dataEventRecordsSecret";
	  });

	services.AddAuthorization(options =>
	{
		options.AddPolicy("dataEventRecordsAdmin", policyAdmin =>
		{
			policyAdmin.RequireClaim("role", "dataEventRecords.admin");
		});
		options.AddPolicy("dataEventRecordsUser", policyUser =>
		{
			policyUser.RequireClaim("role", "dataEventRecords.user");
		});
		options.AddPolicy("dataEventRecords", policyUser =>
		{
			policyUser.RequireClaim("scope", "dataEventRecords");
		});
	});

	services.AddControllers()
		.AddNewtonsoftJson();
}

The Configure method in the startup class adds the security http headers which are used by the browsers. The NWebsec.AspNetCore.Middleware nuget pacakge was using to implement this. The UseRouting method must be added before the UseAuthentication and the UseAuthorization methods, otherwise no security checks will be called, and all requests will be allowed. In my view this is a bug, which is being fixed for the .NET Core 3.0.2 version, I think.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	if (env.IsDevelopment())
	{
		IdentityModelEventSource.ShowPII = true;
		app.UseDeveloperExceptionPage();
	}
	else
	{
		app.UseExceptionHandler("/Home/Error");
		app.UseHsts();
	}

	//Registered before static files to always set header
	app.UseXContentTypeOptions();
	app.UseReferrerPolicy(opts => opts.NoReferrer());
	app.UseCsp(opts => opts
		.BlockAllMixedContent()
		.ScriptSources(s => s.Self())
		.ScriptSources(s => s.UnsafeEval())
		.ScriptSources(s => s.UnsafeInline())
		.StyleSources(s => s.UnsafeInline())
		.StyleSources(s => s.Self())
	);

	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

	app.UseDefaultFiles();
	app.UseStaticFiles();

	//Registered after static files, to set headers for dynamic content.
	app.UseRedirectValidation(t => t.AllowSameHostRedirectsToHttps(44348)); 
	app.UseXXssProtection(options => options.EnabledWithBlockMode());

	app.UseRouting();

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

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

Securing the Vue.js APP

The Vue.js app is secured using the OpenID Connect Code flow with PKCE, implemented using the oidc-client npm package.

import { UserManager, WebStorageStateStore, User } from 'oidc-client';

export default class AuthService {
    private userManager: UserManager;

    constructor() {
        const STS_DOMAIN: string = 'https://localhost:44348';

        const settings: any = {
            userStore: new WebStorageStateStore({ store: window.localStorage }),
            authority: STS_DOMAIN,
            client_id: 'vuejs_code_client',
            redirect_uri: 'https://localhost:44341/callback.html',
            automaticSilentRenew: true,
            silent_redirect_uri: 'https://localhost:44341/silent-renew.html',
            response_type: 'code',
            scope: 'openid profile dataEventRecords',
            post_logout_redirect_uri: 'https://localhost:44341/',
            filterProtocolClaims: true,
        };

        this.userManager = new UserManager(settings);
    }

    public getUser(): Promise<User | null> {
        return this.userManager.getUser();
    }

    public login(): Promise<void> {
        return this.userManager.signinRedirect();
    }

    public logout(): Promise<void> {
        return this.userManager.signoutRedirect();
    }

    public getAccessToken(): Promise<string> {
        return this.userManager.getUser().then((data: any) => {
            return data.access_token;
        });
    }
}

IdentityServer4 Server configuration

The OIDC token server configuration must match the two clients from the application, one client for the Vue.js UI part, and one client for the API.

// Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.

using System.Collections.Generic;
using IdentityServer4.Models;
using Microsoft.Extensions.Configuration;

namespace StsServerIdentity
{
    public class Config
    {
        public static IEnumerable<ApiScope> GetApiScopes()
        {
            return new List<ApiScope>
            {
                new ApiScope("dataEventRecords", "Scope for the dataEventRecords ApiResource",
                    new List<string> { "role", "admin", "user", "dataEventRecords", "dataEventRecords.admin", "dataEventRecords.user"}),
                new ApiScope("securedFiles",  "Scope for the securedFiles ApiResource",
                    new List<string> { "role", "admin", "user", "securedFiles", "securedFiles.admin", "securedFiles.user" })
            };
        }

        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Email(),
                new IdentityResource("dataeventrecordsscope",new []{ "role", "admin", "user", "dataEventRecords", "dataEventRecords.admin" , "dataEventRecords.user" } )
            };
        }

        public static IEnumerable<ApiResource> GetApiResources(IConfigurationSection authSecretsConfiguration)
        {
            var apiSecret = authSecretsConfiguration["ApiSecret"];

            return new List<ApiResource>
            {
                new ApiResource("DataEventRecordsApi")
                {
                    ApiSecrets =
                    {
                        new Secret(apiSecret.Sha256())
                    },
                    Scopes = new List<string> { "dataEventRecords" },
                    UserClaims = { "role", "admin", "user", "dataEventRecords", "dataEventRecords.admin", "dataEventRecords.user" }
                }
            };
        }

        public static IEnumerable<Client> GetClients(IConfigurationSection authConfiguration)
        {
            var vueJsApiUrl = authConfiguration["VueJsApiUrl"];
            return new List<Client>
            {
                new Client
                {
                    ClientName = "vuejs_code_client",
                    ClientId = "vuejs_code_client",
                    AccessTokenType = AccessTokenType.Reference,
                    // RequireConsent = false,
                    AccessTokenLifetime = 330,// 330 seconds, default 60 minutes
                    IdentityTokenLifetime = 300,

                    RequireClientSecret = false,
                    AllowedGrantTypes = GrantTypes.Code,
                    RequirePkce = true,

                    AllowAccessTokensViaBrowser = true,
                    RedirectUris = new List<string>
                    {
                        vueJsApiUrl,
                        $"{vueJsApiUrl}/callback.html",
                        $"{vueJsApiUrl}/silent-renew.html"
                    },
                    PostLogoutRedirectUris = new List<string>
                    {
                        $"{vueJsApiUrl}/",
                        $"{vueJsApiUrl}"
                    },
                    AllowedCorsOrigins = new List<string>
                    {
                        $"{vueJsApiUrl}"
                    },
                    AllowedScopes = new List<string>
                    {
                        "openid",
                        "dataEventRecords",
                        "dataeventrecordsscope",
                        "role",
                        "profile",
                        "email"
                    }
                },
            };
        }
    }
}

The claims used for the API authorization are added using the IProfileService interface.

using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using IdentityModel;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using StsServerIdentity.Models;
using Microsoft.AspNetCore.Identity;
using IdentityServer4;

namespace StsServerIdentity
{
    public class IdentityWithAdditionalClaimsProfileService : IProfileService
    {
        private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory;
        private readonly UserManager<ApplicationUser> _userManager;

        public IdentityWithAdditionalClaimsProfileService(UserManager<ApplicationUser> userManager, IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory)
        {
            _userManager = userManager;
            _claimsFactory = claimsFactory;
        }

        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var sub = context.Subject.GetSubjectId();

            var user = await _userManager.FindByIdAsync(sub);
            var principal = await _claimsFactory.CreateAsync(user);

            var claims = principal.Claims.ToList();

            claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList();
            claims.Add(new Claim(JwtClaimTypes.GivenName, user.UserName));
            claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.user"));
            claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords"));
            claims.Add(new Claim(JwtClaimTypes.Scope, "dataEventRecords"));

            if (user.IsAdmin)
            {
                claims.Add(new Claim(JwtClaimTypes.Role, "admin"));
            }
            else
            {
                claims.Add(new Claim(JwtClaimTypes.Role, "user"));
            }

            claims.Add(new Claim(IdentityServerConstants.StandardScopes.Email, user.Email));

            context.IssuedClaims = claims;
        }

        public async Task IsActiveAsync(IsActiveContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await _userManager.FindByIdAsync(sub);
            context.IsActive = user != null;
        }
    }
}

Now a VUE.js UI can be used together with an ASP.NET Core 3.0 API application in one project. These can be deployed as one deployment and share the same domain. In this example token security was used, but it should be possible to use cookie security with this type of deployment. Some of the debugging dynamics of the Vue.js could be improved, for example, you need to refresh the browser, to see the latest changes of the UI. Hot reload would be nice here. Maybe at some stage, Microsoft might implement a template for this type of application, but at present none exists. Before you could deploy this, the hardcoded URLs for the different parts, would need to be moved to configuration files.

Links:

https://cli.vuejs.org/

https://docs.microsoft.com/en-us/aspnet/core/?view=aspnetcore-3.0

http://docs.identityserver.io/en/latest/

Securing a Vue.js app using OpenID Connect Code Flow with PKCE and IdentityServer4

8 comments

  1. […] Building and securing an ASP.NET Core API with a hosted Vue.js UI […]

  2. Ben Hayat · · Reply

    Hi;

    >>
    // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
    // Licensed under the Apache License, Version 2.0. See LICENSE in the project root
    <<

    Quick question.
    Since you were developing an ASP.Net Core 3.x, why didn't you use IdentiyServer4 V3.x than the v2?

    Thanks for the article.
    ..Ben

    1. Hi Ben yes, this code is directly from the IdentityServer4 examples, so I left their header in it.

      Greetings Damien

  3. Ben Hayat · · Reply

    Thanks for confirmation, as their old comment can be misleading for version 3 users.

    1. ah, thanks, maybe I’ll clean up this up, thanks for the hint

  4. […] Building and securing an ASP.NET Core API with a hosted Vue.js UI – Damien Bowden […]

  5. […] Building and securing an ASP.NET Core API with a hosted Vue.js UI (Damien Bowden) […]

  6. […] Building and securing an ASP.NET Core API with a hosted Vue.js UI […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.