asp.net-coresignalrazure-ad-graph-apiazure-signalr

Transitioning from Asp.Net Core SignalR to Azure SignalR


We currently have an API that is authenticated using Azure Active Directory. This API is accessed by our Teams App and call the Microsoft Graph (utilizing the On-Behalf-Of flow). We are using Asp.Net Core SignalR (dotnet 6) at the moment, but we would like to transition to Azure SignalR.

We have set up the API according to this documentation, and it is functioning as expected.

My program.cs look like this when configuring the authentication


builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration, "AzureAd")
                .EnableTokenAcquisitionToCallDownstreamApi()
                .AddMicrosoftGraph(builder.Configuration.GetSection("DownstreamApi"))
                .AddInMemoryTokenCaches();

            builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // We don't validate issuer as the application is multi tenant and can be any Azure AD tenant
                    // We don't validate audience as the application should use the OnBehalf flow
                    ValidateIssuer = false,
                    ValidateAudience = false,
                };

                var originalOnMessageReceived = options.Events.OnMessageReceived;
                options.Events.OnMessageReceived = async context =>
                {
                    await originalOnMessageReceived(context);

                    var accessToken = context.Request.Query["access_token"];
                    var path = context.HttpContext.Request.Path;
                    if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(Hub.Url))
                    {
                        context.Token = accessToken;
                    }
                };
            });

My Hub is configured as below

[Authorize]
public class TeamsHub : Hub 
{
    private readonly GraphServiceClient _client;
    public TeamsHub(GraphServiceClient client)
    {
        _client = client;
    }

    public async Task ConnectUser(Guid meetingId)
    {
        if (Context.User == null) return;

        var userAadId = Context.User.Claims.GetUserAadId();

        await _client.Users[userAadId.ToString()].Presence.SetPresence("Available", "Available").Request().PostAsync();
    }
}

My signalR is configured as below

builder.Services.AddSignalR(o =>
            {
                // register the middleware as filter to enable the multi-tenant with EF Core
                o.AddFilter<MultiTenantServiceMiddleware>();
                o.AddFilter<EventLoggingHubFilter>();
                o.EnableDetailedErrors = true;
            }).AddJsonProtocol(options =>
            {
                options.PayloadSerializerOptions.Converters
                    .Add(new JsonStringEnumConverter());
            });

Everything works well with Asp.Net Core SignalR. However, as soon as I add AddAzureSignalR(), the connection works, I receive the event, but the request to the Graph fails, stating that my user needs to perform an incremental consent.

Is there anything that i am missing ?


Solution

  • It appears that Azure Signal R token cannot be exchanged into a graph token. To retrieve an exchangeable token, I've updated my client hub endpoint to add the token as a query string:

            const getToken = async () => await microsoftTeams.authentication.getAuthToken();
            const endpoint = configurationContext.data.signalrEndpoint+`?graph_token=${await getToken()}`;
            const hubConnection = new signalR.HubConnectionBuilder()
                .withUrl(endpoint, { accessTokenFactory: getToken })
                .configureLogging(signalR.LogLevel.Information)
                .withAutomaticReconnect()
                .build();
            await hubConnection.start();
    

    Then on the server side, i've added a HubFilter that retrieve the token from the query string and store it into a class resoleved through DI.

    public class GraphTokenHubFilter : IHubFilter
    {
        private readonly ISignalrGraphTokenProvider _tokenProvider;
    
        public GraphTokenHubFilter(ISignalrGraphTokenProvider tokenProvider)
        {
            _tokenProvider = tokenProvider;
        }
    
        /// <summary>
        /// Read the graph token from the query string and set it in the <see cref="ISignalrGraphTokenProvider"/>.
        /// </summary>
        /// <param name="invocationContext"></param>
        /// <param name="next"></param>
        /// <returns></returns>
        public ValueTask<object> InvokeMethodAsync(HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object>> next)
        {
            var httpContext = invocationContext.Context.GetHttpContext();
            if (httpContext != null && httpContext.Request.Query.TryGetValue("graph_token", out var graphToken))
            {
                _tokenProvider.SetToken(graphToken);
            }
            return next(invocationContext);
        }
    }
    
    

    Finally I created a factory that either return the Standard Graph Client (for the api) or create a new one for SignalR

    public class GraphServiceFactory : IGraphServiceFactory
    {
        private readonly HttpClient _httpClient;
        private readonly ISignalrGraphTokenProvider _tokenProvider;
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly GraphApiOptions _graphApiOptions;
        private readonly GraphServiceClient _graphServiceClient;
        private readonly string[] _graphApiScope;
        private readonly string[] _acsScope;
    
        
        public GraphServiceFactory(HttpClient client, ISignalrGraphTokenProvider tokenProvider,
            ITokenAcquisition tokenAcquisition, IOptions<FrontOptions> frontOptions, IOptions<GraphApiOptions> graphApiOptions,
            GraphServiceClient graphServiceClient)
        {
            _httpClient = client;
            _tokenProvider = tokenProvider;
            _tokenAcquisition = tokenAcquisition;
            _graphApiOptions = graphApiOptions.Value;
            _graphServiceClient = graphServiceClient;
            _acsScope = frontOptions.Value.AcsScopes.Split(" ");
            _graphApiScope = frontOptions.Value.GraphScopes.Split(" ");
        }
    
        public async Task<GraphServiceClient> GetClientAsync(GraphScope tokenScope)
        {
            var scopes = tokenScope == GraphScope.GraphApi ? _graphApiScope : _acsScope;
            try
            {
                await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);
            }
            catch (Exception)
            {
                var confidentialClientApplication = ConfidentialClientApplicationBuilder
                    .Create(_graphApiOptions.ClientId)
                    .WithClientSecret(_graphApiOptions.ClientSecret)
                    .WithAuthority(AzureCloudInstance.AzurePublic, _graphApiOptions.TenantId)
                    .Build();
                return new GraphServiceClient(new DelegateAuthenticationProvider(async requestMessage =>
                {
                    var userAssertion = _tokenProvider.Token;
                    var result  = await confidentialClientApplication.AcquireTokenOnBehalfOf(scopes, new UserAssertion(userAssertion)).ExecuteAsync();
                    requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken);
                }), new SimpleHttpProvider(_httpClient));
            }
    
            return _graphServiceClient;
        }
        
    }
    

    It's not the state of the art, but it works for now :)