Securing an Angular SignalR client using JWT tokens with ASP.NET Core and IdentityServer4

This post shows how an Angular SignalR client can send secure messages using JWT bearer tokens with an API and an STS server. The STS server is implemented using IdentityServer4 and the API is implemented using ASP.NET Core.

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

Posts in this series:

History

2017-11-05 Updated to Angular 5 and Typescript 2.6.1, SignalR 1.0.0-alpha2-final

SignalR and SPAs

At present there are 3 ways which SignalR could be secured:

Comment from Damien Edwards:
The debate over HTTPS URLs (including query strings) is long and on-going. Yes, it’s not ideal to send sensitive data in the URL even when over HTTPS. But the fact remains that when using the browser WebSocket APIs there is no other way. You only have 3 options:

  • Use cookies
  • Send tokens in query string
  • Send tokens over the WebSocket itself after onconnect

A usable sample of the last would be interesting in my mind, but I’m not expecting it to be trivial.

For an SPA client, cookies is not an option and should not be used. It is unknown if the 3rd option will work, so at present, the only way to do this, is to send the access token in the query string using HTTPS. Sending tokens in the query string has its problems, which you will need to accept and/or setup you deployment, logging to protect againt these increased risks when compared with sending the access token in the header.

Setup

The demo app is setup using 3 different projects, the API which hosts the SignalR Hub and the APIs, the STS server using ASP.NET Core and IdentityServer4 and the client application using Angular hosted in ASP.NET Core.

The client is secured using the OpenID Implicit Flow using the “id_token token” flow. The access token is then used to access the API, for both the SignalR messages and also the API calls.
All three apllications run using HTTPS.

Securing the SignalR Hub on the API

The SignalR Hub uses the Authorize attribute like any ASP.NET Core MVC controller. Policies and the scheme can be defined here. The Hub uses the Bearer AuthenticationSchemes.

using ApiServer.Providers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;

namespace ApiServer.SignalRHubs
{
    [Authorize(AuthenticationSchemes = "Bearer")]
    public class NewsHub : Hub
    {
        private NewsStore _newsStore;

        public NewsHub(NewsStore newsStore)
        {
            _newsStore = newsStore;
        }

        ...
    }
}

The API project configures the API security in the Startup class in the ConfigureServices method. Firstly CORS is configured, incorrectly in this example as it allows everything. Only the required URLs should be allowed. Then the TokenValidationParameters and the JwtSecurityTokenHandler options are configured. The NameClaimType is configured so that the Name property is set from the token in the HTTP context Identity. This is set and added to the access token on the STS server.

The AddAuthentication is added with the JwtBearer token options. This is configured to accept the token in the query string as well as the header. If the request matches the SignalR Hubs, the token is received and used to validate the request.

The AddAuthorization is added and the policies are defined as required. Then the SignalR middleware is added.

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

	var cert = new X509Certificate2(Path.Combine(_env.ContentRootPath, "damienbodserver.pfx"), "");

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

	// used for the new items which belong to the signalr hub
	services.AddDbContext<NewsContext>(options =>
		options.UseSqlite(
			defaultConnection
		), ServiceLifetime.Singleton
	);

	services.AddSingleton<IAuthorizationHandler, CorrectUserHandler>();
	services.AddSingleton<NewsStore>();

	var policy = new Microsoft.AspNetCore.Cors.Infrastructure.CorsPolicy();
	policy.Headers.Add("*");
	policy.Methods.Add("*");
	policy.Origins.Add("*");
	policy.SupportsCredentials = true;

	services.AddCors(x => x.AddPolicy("corsGlobalPolicy", policy));

	var guestPolicy = new AuthorizationPolicyBuilder()
		.RequireClaim("scope", "dataEventRecords")
		.Build();

	var tokenValidationParameters = new TokenValidationParameters()
	{
		ValidIssuer = "https://localhost:44318/",
		ValidAudience = "dataEventRecords",
		IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("dataEventRecordsSecret")),
		NameClaimType = "name",
		RoleClaimType = "role", 
	};

	var jwtSecurityTokenHandler = new JwtSecurityTokenHandler
	{
		InboundClaimTypeMap = new Dictionary<string, string>()
	};

	services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
	.AddJwtBearer(options =>
	{
		options.Authority = "https://localhost:44318/";
		options.Audience = "dataEventRecords";
		options.IncludeErrorDetails = true;
		options.SaveToken = true;
		options.SecurityTokenValidators.Clear();
		options.SecurityTokenValidators.Add(jwtSecurityTokenHandler);
		options.TokenValidationParameters = tokenValidationParameters;
		options.Events = new JwtBearerEvents
		{
			OnMessageReceived = context =>
			{
				if (context.Request.Path.Value.StartsWith("/loo") &&
					context.Request.Query.TryGetValue("token", out StringValues token)
				)
				{
					context.Token = token;
				}

				return Task.CompletedTask;
			},
			OnAuthenticationFailed = context =>
			{
				var te = context.Exception;
				return Task.CompletedTask;
			}
		};
	});

	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");
		});
		options.AddPolicy("correctUser", policyCorrectUser =>
		{
			policyCorrectUser.Requirements.Add(new CorrectUserRequirement());
		});
	});

	services.AddSignalR();

	services.AddMvc(options =>
	{
	   //options.Filters.Add(new AuthorizeFilter(guestPolicy));
	}).AddJsonOptions(options =>
	{
		options.SerializerSettings.ContractResolver = new DefaultContractResolver();
	});

	services.AddScoped<IDataEventRecordRepository, DataEventRecordRepository>();
}

The Configure method in the Startup class of the API defines the SignalR Hubs and adds the Authentication.

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
	loggerFactory.AddConsole();
	loggerFactory.AddDebug();

	loggerFactory.AddSerilog();

	app.UseExceptionHandler("/Home/Error");
	app.UseCors("corsGlobalPolicy");
	app.UseStaticFiles();

	app.UseAuthentication();

	app.UseSignalR(routes =>
	{
		routes.MapHub<LoopyHub>("loopy");
		routes.MapHub<NewsHub>("looney");
	});

	app.UseMvc(routes =>
	{
		routes.MapRoute(
			name: "default",
			template: "{controller=Home}/{action=Index}/{id?}");
	});
}

Securing the SignalR client in Angular

The Angular SPA application is secured using the oidc Implicit Flow. After a successful client and identity login, the access token can be used to access the Hub or the API. The Hub is initialized after the client has recieved an access token. The Hub connection is then setup, using the same parameter logic defined on the API server. “token=…” Now each message is sent using the access token.

import 'rxjs/add/operator/map';
import { Subscription } from 'rxjs/Subscription';

import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';

import { HubConnection } from '@aspnet/signalr-client';
import { NewsItem } from './models/news-item';
import { Store } from '@ngrx/store';
import * as NewsActions from './store/news.action';
import { Configuration } from '../app.constants';
import { OidcSecurityService } from 'angular-auth-oidc-client';

@Injectable()
export class NewsService {

    private _hubConnection: HubConnection;
    private actionUrl: string;
    private headers: HttpHeaders;

    isAuthorizedSubscription: Subscription;
    isAuthorized: boolean;

    constructor(private http: HttpClient,
        private store: Store<any>,
        private configuration: Configuration,
        private oidcSecurityService: OidcSecurityService
    ) {
        this.actionUrl = `${this.configuration.Server}api/news/`;

        this.headers = new HttpHeaders();
        this.headers = this.headers.set('Content-Type', 'application/json');
        this.headers = this.headers.set('Accept', 'application/json');

        this.init();
    }

    send(newsItem: NewsItem): NewsItem {
        this._hubConnection.invoke('Send', newsItem);
        return newsItem;
    }

    joinGroup(group: string): void {
        this._hubConnection.invoke('JoinGroup', group);
    }

    leaveGroup(group: string): void {
        this._hubConnection.invoke('LeaveGroup', group);
    }

    getAllGroups(): Observable<string[]> {

        const token = this.oidcSecurityService.getToken();
        if (token !== '') {
            const tokenValue = 'Bearer ' + token;
            this.headers = this.headers.append('Authorization', tokenValue);
        }

        return this.http.get<string[]>(this.actionUrl, { headers: this.headers });
    }

    private init() {
        this.isAuthorizedSubscription = this.oidcSecurityService.getIsAuthorized().subscribe(
            (isAuthorized: boolean) => {
                this.isAuthorized = isAuthorized;
                if (this.isAuthorized) {
                    this.initHub();
                }
            });
        console.log('IsAuthorized:' + this.isAuthorized);
    }

    private initHub() {
        console.log('initHub');
        const token = this.oidcSecurityService.getToken();
        let tokenValue = '';
        if (token !== '') {
            tokenValue = '?token=' + token;
        }

        this._hubConnection = new HubConnection(`${this.configuration.Server}looney${tokenValue}`);

        this._hubConnection.on('Send', (newsItem: NewsItem) => {
            this.store.dispatch(new NewsActions.ReceivedItemAction(newsItem));
        });

        this._hubConnection.on('JoinGroup', (data: string) => {
            this.store.dispatch(new NewsActions.ReceivedGroupJoinedAction(data));
        });

        this._hubConnection.on('LeaveGroup', (data: string) => {
            this.store.dispatch(new NewsActions.ReceivedGroupLeftAction(data));
        });

        this._hubConnection.on('History', (newsItems: NewsItem[]) => {
            this.store.dispatch(new NewsActions.ReceivedGroupHistoryAction(newsItems));
        });

        this._hubConnection.start()
            .then(() => {
                console.log('Hub connection started')
            })
            .catch(() => {
                console.log('Error while establishing connection')
            });
    }

}

Or here’s a more simple example with everything in the Angular component.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs/Subscription';
import { Observable } from 'rxjs/Observable';
import { HubConnection } from '@aspnet/signalr-client';
import { Configuration } from '../../app.constants';
import { OidcSecurityService } from 'angular-auth-oidc-client';

@Component({
    selector: 'app-home-component',
    templateUrl: './home.component.html'
})

export class HomeComponent implements OnInit, OnDestroy {
    private _hubConnection: HubConnection;
    async: any;
    message = '';
    messages: string[] = [];

    isAuthorizedSubscription: Subscription;
    isAuthorized: boolean;

    constructor(
        private configuration: Configuration,
        private oidcSecurityService: OidcSecurityService
    ) {
    }

    ngOnInit() {
        this.isAuthorizedSubscription = this.oidcSecurityService.getIsAuthorized().subscribe(
            (isAuthorized: boolean) => {
                this.isAuthorized = isAuthorized;
                if (this.isAuthorized) {
                    this.init();
                }
            });
        console.log('IsAuthorized:' + this.isAuthorized);
    }

    ngOnDestroy(): void {
        this.isAuthorizedSubscription.unsubscribe();
    }

    sendMessage(): void {
        const data = `Sent: ${this.message}`;

        this._hubConnection.invoke('Send', data);
        this.messages.push(data);
    }

    private init() {

        const token = this.oidcSecurityService.getToken();
        let tokenValue = '';
        if (token !== '') {
            tokenValue = '?token=' + token;
        }

        this._hubConnection = new HubConnection(`${this.configuration.Server}loopy${tokenValue}`);

        this._hubConnection.on('Send', (data: any) => {
            const received = `Received: ${data}`;
            this.messages.push(received);
        });

        this._hubConnection.start()
            .then(() => {
                console.log('Hub connection started')
            })
            .catch(err => {
                console.log('Error while establishing connection')
            });
    }
}

Logs on the server

As the application sends the token in the query string, this can be accessed on the server using standard logging. The 3 applications are configured using Serilog and the logs are saved to Seq server and also to log files. If you open the Seq server, the access_token can be viewed and copied.

You can then use jwt.io to view the details of the token.
https://jwt.io/

Or you can use postman to do API calls for which you might not have the authorization or authentication rights. This token will work as long as it is valid. All you need to do, is add this to the header and you have the same rights as the identity for which the access token was created.

You can also view the token in the log files:

2017-10-15 17:11:33.790 +02:00 [Information] Request starting HTTP/1.1 OPTIONS http://localhost:44390/looney?token=eyJhbGciOiJSUzI1NiIsIm... 
2017-10-15 17:11:33.795 +02:00 [Information] Request starting HTTP/1.1 OPTIONS http://localhost:44390/loopy?token=eyJhbGciOiJSUzI1NiIsImtpZCI6IjA2RDNFNDZFO...
2017-10-15 17:11:33.803 +02:00 [Information] Policy execution successful.

Due to this, you need to check that the deployment admin, developers, devop people can be trusted or reduce the access to the production scenarios. This has also implications with GDPR.

Links

https://github.com/aspnet/SignalR

https://github.com/aspnet/SignalR#readme

https://github.com/aspnet/SignalR/issues/888

https://github.com/ngrx

https://www.npmjs.com/package/@aspnet/signalr-client

https://dotnet.myget.org/F/aspnetcore-ci-dev/api/v3/index.json

https://dotnet.myget.org/F/aspnetcore-ci-dev/npm/

https://dotnet.myget.org/feed/aspnetcore-ci-dev/package/npm/@aspnet/signalr-client

https://www.npmjs.com/package/msgpack5

Advertisements

2 comments

  1. […] Securing an Angular SignalR client using JWT tokens with ASP.NET Core and IdentityServer4 – Damien Bowden […]

  2. […] Securing an Angular SignalR client using JWT tokens with ASP.NET Core and IdentityServer4 (Damien Bowden) […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: