4
votes

I have a SPA (angular 7) and an API (.Net Core) which I authenticate with Azure AD. I'm using adal-angular4 to integrate my angular application with AAD.

Everything works great, but I'm also using SignalR with the API as server and when I try to connect from my SPA I get 401 Unauthorized on the negotiate "request" and I get this back in the Response Headers:

Response Header

The request contains my Bearer token in the Authorization header, and when I run the token through jwt.io, I can see that the "aud" value is the Azure AD ClientId for my SPA.

All regular request to the API contains the same token and I have no issues with those. I have [Authorize] on all my Controllers and on my Hub, but it's only the SignalR Hub that causes this issue.

My server Startup:

public Startup(IConfiguration configuration, IHostingEnvironment env)
{
    Configuration = configuration;
    _env = env;
}

public IConfiguration Configuration { get; }
private IHostingEnvironment _env;
public void ConfigureServices(IServiceCollection services)
{

    StartupHandler.SetupDbContext(services, Configuration.GetConnectionString("DevDb"));


    // Setup Authentication
    services.AddAuthentication(AzureADDefaults.BearerAuthenticationScheme)
        .AddAzureADBearer(options =>
        {
            Configuration.Bind("AzureAD", options);


        });

    services.AddMvc()
        .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

    // Add functionality to inject IOptions<T>
    services.AddOptions();

    // Add AzureAD object so it can be injected
    services.Configure<AzureAdConfig>(Configuration.GetSection("AzureAd"));

    services.AddSignalR(options =>
    {
        options.EnableDetailedErrors = true;
        options.KeepAliveInterval = TimeSpan.FromSeconds(10);
    });
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseDeveloperExceptionPage();
        app.UseHsts();
    }

    app.UseCookiePolicy();

    app.UseHttpsRedirection();

    //app.UseCors("AllowAllOrigins");
    app.UseCors(builder =>
    {
        builder.AllowAnyOrigin();
        builder.AllowAnyMethod().AllowAnyHeader();
        builder.AllowCredentials();
    });


    app.UseAuthentication();

    app.UseSignalR(routes => routes.MapHub<MainHub>("/mainhub"));

    app.UseStaticFiles(new StaticFileOptions()
    {
        FileProvider = new PhysicalFileProvider(Path.Combine(_env.ContentRootPath, "Files")),
        RequestPath = new PathString("/Files")
    });

    app.UseMvc();
}

My SignalR Hub:

[Authorize]
public class MainHub : Hub
{
    private readonly IEntityDbContext _ctx;

    public MainHub(IEntityDbContext ctx)
    {
        _ctx = ctx;
        _signalRService = signalRService;
    }

    public override Task OnConnectedAsync()
    {
        return base.OnConnectedAsync();
    }

    public override Task OnDisconnectedAsync(Exception exception)
    {
        return base.OnDisconnectedAsync(exception);
    }
}

And this is my SignalRService on my angular client. I'm running startConnection() in the constructor of app.component.ts.

export class SignalRService {
    private hubConnection: signalR.HubConnection;

    constructor(private adal: AdalService) {}

    startConnection(): void {
        this.hubConnection = new signalR.HubConnectionBuilder()
            .withUrl(AppConstants.SignalRUrl, { accessTokenFactory: () => this.adal.userInfo.token})
            .build();

        this.hubConnection.serverTimeoutInMilliseconds = 60000;

        this.hubConnection.on('userConnected', (user) => 
        {
            console.log(user);
        });

        this.hubConnection.start()
            .then(() => console.log('Connection started'))
            .catch(err => 
            {
                console.log('Error while starting connection: ' + err);
            });
    }
}

I have tried this solution, but I can't get that to work either.

Edit

When I've implemented the solution from the official docs, the API stops working on regular requests as well and I get back:

Signature key was not found

I've populate the IssuerSigningKey property in TokenValidationParameters with new SymmetricSecurityKey(Guid.NewGuid().ToByteArray());. Am I doing anything wrong here?

/EDIT

Why won't SignalR accept my accesstoken when the API otherwise accept it?

3
if using AddAzureADBearer by setting correct Instance and domain And if using AddJwtBearer add options.Authority = https://login.microsoftonline.com/Yourtenant , delete ` IssuerSigningKey = SecurityKey`Nan Yu
I tried adding options.Authority and removed IssuerSigningKey, But I got an InvalidOperationException: InvalidOperationException: IDX20803: Unable to obtain configuration from: '[PII is hidden]'. But the Event OnMessageRecieved gets executed now, so it's a step forward.Martin Johansson
Add / to endpoint , and confirm that : options.Authority = "https://login.microsoftonline.com/yourtenant.onmicrosoft.com/" , also any detail exception message ?Nan Yu
It worked, thank you! Add your solution as an answer and I'll mark it as accepted.Martin Johansson
Ok, i will also explain a bit about that .Nan Yu

3 Answers

2
votes

Just have a look at the official docs. You need a special handling for the JWT Bearer events so your authentication is working. The token needs to be forwarded to the hubs. Have a look at the part where I said

THAT PART IS MISSING

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    services.AddIdentity<ApplicationUser, IdentityRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>()
        .AddDefaultTokenProviders();

    services.AddAuthentication(options =>
        {
            // Identity made Cookie authentication the default.
            // However, we want JWT Bearer Auth to be the default.
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(options =>
        {
            // Configure JWT Bearer Auth to expect our security key
            options.TokenValidationParameters =
                new TokenValidationParameters
                {
                    LifetimeValidator = (before, expires, token, param) =>
                    {
                        return expires > DateTime.UtcNow;
                    },
                    ValidateAudience = false,
                    ValidateIssuer = false,
                    ValidateActor = false,
                    ValidateLifetime = true,
                    IssuerSigningKey = SecurityKey
                };

            //THAT IS THE PART WHICH IS MISSING IN YOUR CONFIG !
            // We have to hook the OnMessageReceived event in order to
            // allow the JWT authentication handler to read the access
            // token from the query string when a WebSocket or 
            // Server-Sent Events request comes in.
            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    var accessToken = context.Request.Query["access_token"];

                    // If the request is for our hub...
                    var path = context.HttpContext.Request.Path;
                    if (!string.IsNullOrEmpty(accessToken) &&
                        (path.StartsWithSegments("/hubs/chat")))
                    {
                        // Read the token out of the query string
                        context.Token = accessToken;
                    }
                    return Task.CompletedTask;
                }
            };
        });

    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

    services.AddSignalR();

    // Change to use Name as the user identifier for SignalR
    // WARNING: This requires that the source of your JWT token 
    // ensures that the Name claim is unique!
    // If the Name claim isn't unique, users could receive messages 
    // intended for a different user!
    services.AddSingleton<IUserIdProvider, NameUserIdProvider>();

    // Change to use email as the user identifier for SignalR
    // services.AddSingleton<IUserIdProvider, EmailBasedUserIdProvider>();

    // WARNING: use *either* the NameUserIdProvider *or* the 
    // EmailBasedUserIdProvider, but do not use both. 
}
1
votes

Change the Authorize attribute on your hub to

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
1
votes

When validating the signature of access token , you should get the public key since Azure AD may sign token using any one of a certain set of public-private key pairs , the keys could be found at :

https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration 

Within the JSON response, you’ll see a property jwks_uri which is the URI that contains the JSON Web Key Set for Azure AD. Matching the kid claim in jwt token , you can find the key which AAD used to sign the token with asymmetric encryption algorithms, such as RSA 256 by default .

In asp.net core apis , when validating the access token which issued by Azure AD , you can use AddJwtBearer extension and provide the correct Authority , so that middleware will correctly get the keys from Azure AD OpenID configuration endpoint :

options.Authority = "https://login.microsoftonline.com/yourtenant.onmicrosoft.com/"

Another choice is to use AddAzureADBearer extension from Microsoft.AspNetCore.Authentication.AzureAD.UI library . You should also set correct authority(instance + domain) , middleware will help validating the signature and claims based on your configuration .