Dejan Janjušević
Dejan Janjušević

Reputation: 3230

OIDC correlation failed in Microsoft Teams authentication popup (no problems in browser)

Using ASP.NET Core w/ .NET Core 3.1.
OIDC authentication flow handled by Microsoft.AspNetCore.Authentication.OpenIdConnect.
After I started getting the error, I have actually included the above namespace into my project so I can set breakpoints and inspect data easily.

According to this document: https://developer.microsoft.com/en-us/office/blogs/authentication-in-microsoft-teams-apps-tabs/ what I'm trying to achieve should be possible.

Let's say we have configured a tab in Microsoft Teams which is hosted in our ASP.NET Core MVC application at https://localhost:60151 (not via IIS Express, but self-hosted). The MS Teams application can access our application using ngrok, which is started using the command line:

./ngrok http https://localhost:60151

This application has a TabController defined like this:

public class TabController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    [Authorize]
    public IActionResult TabAuthStart()
    {
        return RedirectToAction(nameof(TabAuthEnd), new { serializedClaims = string.Join("; ", User.Claims.Select(x => $"{x.Type}: {x.Value}")) });
    }

    // for simplicity, let's assume no one navigates to this action 
    // except when redirected from TabAuthStart after the authentication flow completes
    public IActionResult TabAuthEnd(string serializedClaims)
    {
        return View(model: serializedClaims);
    }
}

Let the index view be defined like this:

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>MS Teams Tab</title>
    <script src="https://statics.teams.microsoft.com/sdk/v1.4.2/js/MicrosoftTeams.min.js" crossorigin="anonymous"></script>
    <script>

        // Call the initialize API first
        microsoftTeams.initialize();

        function authenticate() {
            microsoftTeams.authentication.authenticate({
                url: window.location.origin + "/tab/tabauthstart",
                successCallback: function (result) {
                    // do something on success
                },
                failureCallback: function (reason) {
                    // do something on failure
                }
            });
        }

    </script>
</head>
<body>
    @if (!User.Identity.IsAuthenticated)
    {
        <button onclick="authenticate()">authenticate</button>
    }
    else
    {
        <p>Hello, @User.Identity.Name</p>
    }
</body>
</html>

When redirected to /tab/tabauthstart, the [Authorize] attribute will make sure the OIDC challenge handler will pick up the request and redirect to the configured IdentityServer authorize page.

Speaking of OIDC handler, it is configured in Startup.cs like this:

services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
    options.ExpireTimeSpan = TimeSpan.FromMinutes(60);
    options.Cookie.Name = "mvchybridautorefresh";
})
.AddOpenIdConnect(options =>
{
    options.Authority = "https://localhost:44333/"; // The local IdentityServer instance
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.ClientId = "msteams";
    options.ResponseType = "code id_token"; // Hybrid flow
    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("offline_access");

    options.ClaimActions.MapAllExcept("iss", "nbf", "exp", "aud", "nonce", "iat", "c_hash");

    options.GetClaimsFromUserInfoEndpoint = true;
    options.SaveTokens = true;

    // The following were added in despair. However, they don't have any effect on the process.
    options.CorrelationCookie.Path = null;
    options.CorrelationCookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None;
    options.CorrelationCookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always;
    options.CorrelationCookie.HttpOnly = false;
});

and then we have a Configure method like this:

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

    app.UseDefaultFiles();
    app.UseStaticFiles();

    app.UseHttpsRedirection();

    app.UseRouting();

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

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=Home}/{action=Index}/{id?}"
        );
    });
}

In IdentityServer, assume the client is configured correctly.
So when we start our application and go to a tab in Microsoft Teams app, we see a button saying "authenticate". Click of that button triggers the OIDC challenge handler which prepares the authentication properties, writes nonce and correlation cookies to the Response.Cookies collection.

After generating the correlation Id, we have the following Request parameters:

The Set-Cookie response header contains the following:

.AspNetCore.OpenIdConnect.Nonce.blabla; expires=Tue, 21 Jan 2020 20:54:28 GMT; path=/signin-oidc; secure; samesite=none; httponly,
.AspNetCore.Correlation.OpenIdConnect.blabla; expires=Tue, 21 Jan 2020 20:58:57 GMT; path=/signin-oidc; secure; samesite=none

After that is done, we are redirected to the IdSrv sign in page.

There we input our sign in details and finish the sign in process, which brings us back to our OIDC handler, which then checks for the existence of the correlation cookie. However, the correlation cookie doesn't exist and thus, the exception is thrown saying "Correlation failed".

These are the request parameters just before the correlation is validated:

The cookies collection is empty. Why?

To make things even more interesting, when we open a browser, navigate to https://[assigned-subdomain].ngrok.io/tab/index and start the authentication by clicking the button, the process completes successfully and we are finally redirected to /tab/tabAuthEnd, whose view, by the way, looks like this:

@model string
@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Authentication successful</title>
    <script src="https://statics.teams.microsoft.com/sdk/v1.4.2/js/MicrosoftTeams.min.js" crossorigin="anonymous"></script>
    <script>

        // Call the initialize API first
        microsoftTeams.initialize();
        microsoftTeams.authentication.notifySuccess(@Model);

    </script>
</head>
<body>

    <p>Redirecting back..</p>

</body>
</html>

So... any clue why the OIDC cookies are not saved when redirecting to the IdSrv login page?

Upvotes: 1

Views: 1876

Answers (2)

Deven Liu
Deven Liu

Reputation: 1

By default, CookiePolicyOptions.Secure has been set to CookieSecurePolicy.SameAsRequest, but cookies will only be transmitted by the browser when CookiePolicyOptions.Secure is set to CookieSecurePolicy.Always.

Upvotes: 0

Mats Magnem
Mats Magnem

Reputation: 1405

You will see that the Set-Cookie response header ends with "secure; samesite=none;" and Teams is based on a Chrome version that does not allow that, and no cookies are stored, causing this problem.

You will also see that setting SameSite to Lax or Strict will not change the Set-Cookie header. You will have to manage this in the Startup class (in aspnetcore) like this:

private void CheckSameSite(HttpContext httpContext, CookieOptions options)
{
    if (options.SameSite == SameSiteMode.None)
    {
        var userAgent = httpContext.Request.Headers["User-Agent"].ToString();
        // TODO: Use your User Agent library of choice here.
        if (/* UserAgent doesn’t support new behavior */)
        {
               // For .NET Core < 3.1 set SameSite = (SameSiteMode)(-1)
               options.SameSite = SameSiteMode.Unspecified;
         }
    }
}

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<CookiePolicyOptions>(options =>
    {
        options.MinimumSameSitePolicy = SameSiteMode.Unspecified;
        options.OnAppendCookie = cookieContext => 
            CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
        options.OnDeleteCookie = cookieContext => 
            CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
    });
}

public void Configure(IApplicationBuilder app)
{
    app.UseCookiePolicy(); // Before UseAuthentication or anything else that writes cookies.
    app.UseAuthentication();
    // …
}

In this is check: if (/* UserAgent doesn’t support new behavior */)

...you check on the User-Agent header. Like fon instance if it contains "Teams" or more specific.

Microsoft Teams Teams identifies by this User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Teams/1.3.00.362 Chrome/66.0.3359.181 Electron/3.1.13 Safari/537.36

The source for this is located here: https://devblogs.microsoft.com/aspnet/upcoming-samesite-cookie-changes-in-asp-net-and-asp-net-core/

Upvotes: 2

Related Questions