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

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 Duende IdentityServer and the API is implemented using ASP.NET Core.

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

Posts in this series

History

2023-01-08 Updated Angular 15, .NET 7
2021-01-25 Updated Angular 11.1.0 .NET 5, ngrx implementation
2020-03-21 updated packages, fixed Admin UI STS
2019-08-18 Updated ASP.NET Core 3.0, Angular 8.2.2
2019-02-06 Updated Angular 7.2.4, latest NGRX, SignalR CORS fix
2018-12-12 Updated .NET Core 2.2, ASP.NET Core SignalR 1.1.0, Angular 7.1.3
2018-05-31 Updated Microsoft.AspNetCore.SignalR 2.1
2018-05-08 Updated Microsoft.AspNetCore.SignalR 2.1 rc1, Angular 6
2018-03-15 Updated signalr Microsoft.AspNetCore.SignalR 1.0.0-preview1-final, Angular 5.2.8, @aspnet/signalr 1.0.0-preview1-update1

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 against 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.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

namespace ApiServer.SignalRHubs;

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class NewsHub : Hub
{
    private readonly NewsStore _newsStore;

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

    public Task Send(NewsItem newsItem)
    {
        if(!_newsStore.GroupExists(newsItem.NewsGroup))
        {
            throw new Exception("cannot send a news item to a group which does not exist.");
        }

        _newsStore.CreateNewItem(newsItem);
        return Clients.Group(newsItem.NewsGroup).SendAsync("Send", newsItem);
    }

   //  ...
}

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. The Configure method in the Startup class of the API defines the SignalR Hubs and adds the Authentication.

using ApiServer.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using ApiServer.Providers;
using ApiServer.SignalRHubs;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Primitives;
using ApiServer.Data;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using Serilog;
using Microsoft.OpenApi.Models;
using ApiServer.Model;
using Microsoft.IdentityModel.Logging;

namespace ApiServer;

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
        IdentityModelEventSource.ShowPII = true;

        services.Configure<ClientAppSettings>("NEWS", Configuration.GetSection("ClientAppSettingsNewsApp"));
        services.Configure<ClientAppSettings>("DM", Configuration.GetSection("ClientAppSettingsDirectMessage"));

        services.AddTransient<DataEventRecordRepository>();
        services.AddSingleton<NewsStore>();
        services.AddSingleton<UserInfoInMemory>();

        var defaultConnection = Configuration.GetConnectionString("DefaultConnection");
        services.AddDbContext<NewsContext>(options => 
            options.UseSqlite(defaultConnection), 
            ServiceLifetime.Singleton
        );

        var sqliteConnectionString = Configuration.GetConnectionString("SqliteConnectionString");
        services.AddDbContext<DataEventRecordContext>(options =>
            options.UseSqlite(sqliteConnectionString)
        );

        services.AddCors(options =>
        {
            options.AddPolicy("AllowMyOrigins",
                builder =>
                {
                    builder
                        .AllowCredentials()
                        .AllowAnyHeader()
                        .SetIsOriginAllowedToAllowWildcardSubdomains()
                        .AllowAnyMethod()
                        .WithOrigins(
                            "https://localhost:44311", 
                            "https://localhost:44390", 
                            "https://localhost:44395", 
                            "https://localhost:5001");
                });
        });

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

        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.Authority = "https://localhost:5001/";
                options.Audience = "dataEventRecordsApi";
                options.IncludeErrorDetails = true;
                options.SaveToken = true;
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidateIssuerSigningKey = true,
                    NameClaimType = "email",
                    RoleClaimType = "role",
                    ValidAudiences = new List<string> { "dataEventRecordsApi" },
                    ValidIssuers = new List<string> { "https://localhost:5001/" }
                };
                options.Events = new JwtBearerEvents
                {
                    OnMessageReceived = context =>
                    {
                        if ( ( context.Request.Path.Value!.StartsWith("/signalrhome")
                            || context.Request.Path.Value!.StartsWith("/looney")
                            || context.Request.Path.Value!.StartsWith("/usersdm") 
                           )
                            && 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();
        services.AddSignalR();
        services.AddControllers();

        services.AddSwaggerGen(c =>
        {
            // add JWT Authentication
            var securityScheme = new OpenApiSecurityScheme
            {
                Name = "JWT Authentication",
                Description = "Enter JWT Bearer token **_only_**",
                In = ParameterLocation.Header,
                Type = SecuritySchemeType.Http,
                Scheme = "bearer", // must be lower case
                BearerFormat = "JWT",
                Reference = new OpenApiReference
                {
                    Id = JwtBearerDefaults.AuthenticationScheme,
                    Type = ReferenceType.SecurityScheme
                }
            };
            c.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme);
            c.AddSecurityRequirement(new OpenApiSecurityRequirement
            {
                {securityScheme, Array.Empty<string>()}
            });

            c.SwaggerDoc("v1", new OpenApiInfo { Title = "APIs", Version = "v1" });
        });
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseDeveloperExceptionPage();

        app.UseSwagger();
        app.UseSwaggerUI(c =>
        {
            c.SwaggerEndpoint("/swagger/v1/swagger.json", "APIs");
            c.RoutePrefix = string.Empty;
        });

        app.UseCors("AllowMyOrigins");

        // https://nblumhardt.com/2019/10/serilog-in-aspnetcore-3/
        // https://nblumhardt.com/2019/10/serilog-mvc-logging/
        app.UseSerilogRequestLogging();

        app.UseRouting();

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

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapHub<UsersDmHub>("/usersdm");
            endpoints.MapHub<SignalRHomeHub>("/signalrhome");
            endpoints.MapHub<NewsHub>("/looney");

            endpoints.MapControllers();
        });
    }
}

Securing the SignalR client in Angular

The Angular SPA application is secured using the OIDC Code 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 received 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 { Observable } from 'rxjs';

import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { HubConnection } from '@microsoft/signalr';
import { NewsItem } from './models/news-item';
import { Store } from '@ngrx/store';
import * as newsAction from './store/news.action';
import { Configuration } from '../app.constants';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import * as signalR from '@microsoft/signalr';
import { map } from 'rxjs/operators';

@Injectable()
export class NewsService {
  private hubConnection: HubConnection | undefined;
  private actionUrl: string;
  private headers: HttpHeaders;
  private token: string = '';
  isAuthorized = false;

  constructor(
    private http: HttpClient,
    private store: Store<any>,
    private configuration: Configuration,
    private oidcSecurityService: OidcSecurityService
  ) {
    console.warn('BEGIN NEWS SERVICE ...')
    this.init();
    this.actionUrl = 'https://localhost:44390/api/news/';

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

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

  sendDirectMessage(message: string, userId: string): string {
    if (this.hubConnection) {
      this.hubConnection.invoke('SendDM', message, userId);
    }
    return message;
  }

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

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

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

  private init() {
    this.oidcSecurityService.isAuthenticated$.subscribe(
      ({isAuthenticated}) => {
        this.isAuthorized = isAuthenticated;
        if (isAuthenticated) {
          this.initHub();
        }
      }
    );

    console.log('IsAuthorized:' + this.isAuthorized);
  }

  private initHub() {
    console.warn('initHub');

    this.oidcSecurityService.isAuthenticated$.subscribe(
      ({isAuthenticated}) => {
        this.isAuthorized = isAuthenticated;
        if (isAuthenticated) {

          this.oidcSecurityService.getAccessToken().subscribe((token) => {

            let tokenValue = '';
            this.token = token;
            const tokenApiHeader = 'Bearer ' + this.token;
            this.headers = this.headers.set('Authorization', tokenApiHeader);
            console.log(tokenApiHeader)
            tokenValue = '?token=' + token;

            this.hubConnection = new signalR.HubConnectionBuilder()
              .withUrl(`${this.configuration.Server}looney${tokenValue}`)
              .configureLogging(signalR.LogLevel.Information)
              .build();

            this.hubConnection.start().catch((err) => console.error(err.toString()));

            this.hubConnection.on('Send', (newsItem: NewsItem) => {
              this.store.dispatch(
                newsAction.receiveNewsItemAction({ payload: newsItem })
              );
            });

            this.hubConnection.on('JoinGroup', (data: string) => {
              console.log('received data from the hub');
              console.log(data);
              this.store.dispatch(
                newsAction.receiveGroupJoinedAction({ payload: data })
              );
            });

            this.hubConnection.on('LeaveGroup', (data: string) => {
              this.store.dispatch(newsAction.receiveGroupLeftAction({ payload: data }));
            });

            this.hubConnection.on('History', (newsItems: NewsItem[]) => {
              console.log('received history from the hub');
              console.log(newsItems);
              this.store.dispatch(
                newsAction.receiveNewsGroupHistoryAction({ payload: newsItems })
              );
            });
          });

        }
      }
    );
  }
}

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

import { Component, OnInit } from '@angular/core';
import { HubConnection } from '@microsoft/signalr';
import { Configuration } from '../../app.constants';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import * as signalR from '@microsoft/signalr';

@Component({
  selector: 'app-home-component',
  templateUrl: './home.component.html',
})
export class HomeComponent implements OnInit {
  private hubConnection: HubConnection | undefined;
  async: any;
  message = '';
  messages: string[] = [];

  isAuthenticated = false;

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

  ngOnInit() {
    this.oidcSecurityService.isAuthenticated$.subscribe(
      ({isAuthenticated}) => {
        this.isAuthenticated = isAuthenticated;
        if (isAuthenticated) {
          this.init();
        }
      }
    );
  }

  sendMessage(): void {
    const data = `Sent: ${this.message}`;
    if (this.hubConnection) {
      this.hubConnection.invoke('Send', data);
    }
    this.messages.push(data);
  }

  private init() {
    let tokenValue = '';
    this.oidcSecurityService.getAccessToken().subscribe((token) => {
      // console.log(token)
      if (token !== '') {
        tokenValue = '?token=' + token;
      }
    });

    this.hubConnection = new signalR.HubConnectionBuilder()
      .withUrl(`${this.configuration.Server}signalrhome${tokenValue}`)
      .configureLogging(signalR.LogLevel.Information)
      .build();

    this.hubConnection.start().catch((err) => console.error(err.toString()));

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

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://learn.microsoft.com/en-us/aspnet/core/signalr/introduction

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

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

https://github.com/ngrx

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

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

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

18 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) […]

  3. Why are Cookies not an option in a Single Page Application?

    1. Hi Markus

      Cookies are not the recommended way to secure SPA applications, OpenID Connect Implicit Flow with tokens. The Cookies require lots of extra protection and mesaures to protect when using with an SPA. I believe the best practices, recommendations should be followed.

      Greetings Damien

  4. Hi, Damien,

    Could you please refer to where those recommendations and best practices are defined, as my research seems to show the opposite, for example https://medium.com/lightrail/getting-token-authentication-right-in-a-stateless-single-page-application-57d0c6474e3

    Thanks!

    1. Hi Markus

      http://openid.net/connect/ (Implicit Flow)

      The link you sent re-invents the wheel, and forgets a whole range of security checks.

      Greetings Damien

      1. Hi, Damien,

        Thanks a lot!

      2. I did some more research, and most articless seem to equal cookie authentication with the old, stateful style of using server side session tracking. (One example is https://auth0.com/blog/cookies-vs-tokens-definitive-guide/).
        But when you have stateless APIs (like REST), that’s not true, you can just put a cryptographically secured set of claims and expiration in the cookie.
        And I see two disadvantages of handling the bearer token explicitly via Auth header in Javascript:
        – The JavaScript has access to the complete token, thus it’s easier for the token to get stolen in case of XSS or script injection attacks etc.
        – Things like images and download links have to be handled with JavaScript magic or adding the token to the URL (thus letting it escape to log files, browser history etc…), as we cannot “add” cookies to “img src” or “a href” tags.

      3. yes and also access tokens with websockets has the same problems as file downloads. If you use ID4, you can replace the jwt bearer access_token with a reference token to the access token. Then you can manage the access token better, for example do a real logout. Once this is correct, then you need to add the HTTPS Headers to protect the session.

        Greetings Damien

  5. after searched for many hours i got the solution thanks a lot

    1. thanks for the feedback

  6. Damien,
    Do you think this would work as well with reference tokens?
    Are the details of this blog post in the latest bits on the repo on github?
    Thanks
    paul.

    1. Hi Paul yes this would work with reference tokens. Use just need to configure the API to use introspection. Nothing really changes then for the UI client.

      Greetings Damien

  7. aaron · · Reply

    Hi is there a way to validate 401 status in front end after token expiration. Thanks

  8. This does not seem to work if the server side has both openid/cookie authentication and jwt bearer authentication configured. It seems the OnMessageReceived event is never called leading me to believe the openid/cookie authentication hijacks the connection and preemptively denies it.

    1. Hi

      This should work if both cookies and JWT is configured on your server but the setup of the middleware is tricky and you need to set the correct scheme. Probably setting this explicitly is required.

      Greetings Damien

      1. I fixed it by explicitly setting the authentication scheme for my SignalR hubs to Bearer

      2. cool, congrats

Leave a comment

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