1

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 ?

Tdc
  • 45
  • 6
  • Have you tried to register a new user, then approve the permissions when you log in the app which use the azure signalr service. Waiting for your update. – Jason Pan Mar 29 '23 at 08:07
  • Hello @JasonPan, yes. I've even created a new demo tenant to try this out. As there are a lot of permissions in my app and some need admin consent, as the admin, i've consented for the entire org. – Tdc Mar 29 '23 at 15:50
  • Unfortunatly no. – Tdc Mar 29 '23 at 17:32
  • I do believe it's something about the `context.Token = accessToken;` because when using AzureSignalR, my breakpoint are not hit. But inside my Hub method, i can see that the token is in the query params. – Tdc Mar 29 '23 at 17:43
  • I investigated further, and the token from ASR and the one I recieve in the `OnMessageReceived ` are fondamentally different. @JasonPan do you know if there are any sample with an Azure AD OBO flow ? – Tdc Mar 29 '23 at 18:39
  • Check this link: https://learn.microsoft.com/en-us/azure/active-directory/develop/sample-v2-code – Jason Pan Mar 30 '23 at 07:49
  • so, i deeply investigate. I believes that azure signalr token cannot be used for token exchange for the Microsoft Graph. To make it work, i updated my clientHub to add the token into the querystring endpoint ``signalrEndpoint+`?graph_token=${await getToken()}`` and getting it on the server side through a hub filter. @JasonPan what do you think of this solution ? – Tdc Mar 31 '23 at 11:48
  • The other idea i had was to add the token to exchange as a claim by implementing my own negatiateEndpoint as seen [here](https://github.com/aspnet/AzureSignalR-samples/tree/main/samples/Management) But it feel more complicated and i've seen that azure signalR might fail if [the token is too long](https://learn.microsoft.com/en-us/azure/azure-signalr/signalr-howto-troubleshoot-guide#access-token-too-long) – Tdc Mar 31 '23 at 12:02

1 Answers1

0

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 :)

Tdc
  • 45
  • 6