Ryan Taylor
Ryan Taylor

Reputation: 8892

"The oauth state was missing or invalid" when signing out due to invalid redirect (ASP.NET Core, OAuth, AspNet.Security.OAuth.Providers)

We have a web app (SPA + ASP.NET Core 6) using ArcGIS Portal configured with Microsoft Entra Id via SAML for authentication. ASP.NET Core uses the ArcGIS provider from AspNet.Security.OAuth.Providers to initiate sign in and sign out flows. In addition there's a requirement to sign out of the IdP whenever we sign out of the app and therein lies our issue.

Whenever we sign out we are mistakenly redirected to an internal middleware route (app/signin-arcgis) that throws the "The oauth state was missing or invalid" error mentioned in the title. While they can still complete sign in they are ultimately redirected to app/error rather than app/index.html. Once authenticated a second time, they user can remove /error from the address bar and access the app.

So my questions are...

  1. Why am I being redirected back to app/signin-arcgis?
  2. How can I change that to app/index.html?
  3. Are there better ways to achieve what I doing?

More details below, including the code, at bottom.

Sign out flow

  1. User clicks "Log Out"
  2. User is redirected to app/logout
  3. app/logout (server-side) calls HttpContext.SignoutAsync to delete own authentication cookie
  4. app/logout (client-side) submits a hidden form to POST a LogoutRequest to the IdP (https://login.microsoftonline.us/{tenant}/saml2)
  5. The IdP signs the user out and is redirected to /portal/sharing/rest/oauth2/saml/signout
  6. That page deletes some portal cookies and returns a Location header value of app/signin-arcgis
  7. User is then redirected to app/signin-arcgis with no parameters
    • I believe that we're redirected here and with no parameters could be the cause of the issue
  8. app/signin-arcgis (server-side) throws the "The oauth state was missing or invalid" error
  9. app/signin-arcgis returns a Location header value of app/login?ReturnUrl=%2Fapp%2Ferror (our global error handler page)
  10. User is redirected to app/login?ReturnUrl=%2Fapp%2Ferror to begin a new auth challenge (as all routes are require authenticated access by default
  11. User is redirected to /portal/sharing/rest/oauth2/authorize?redirect_uri=https%3A%2F%2Fmy.domain.com%2Fapp%2Fsignin-arcgis (removed some parameters for clarity)

If the user clicks "Sign In" to finish the authentication flow they will be successfully signed in but redirected to app/error. If they then remove the /error path from the browser they will have access to the app.

Code

// Program.cs
builder.services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie(options =>
{
    options.LoginPath = "/login";
    options.LogoutPath = "/logout";
    options.Cookie.Name = authOptions.CookieName;

    options.Events.OnSigningOut = async ctx =>
    {
        // code to invalidate refresh_token removed for clarity
    };

}).AddArcGIS(options =>
{
    options.ClientId = authOptions.ClientId;
    options.ClientSecret = authOptions.ClientSecret;

    var baseUri = configuration[_PortalUrlKey];
    options.AuthorizationEndpoint = $"{baseUri}/sharing/rest/oauth2/authorize";
    options.TokenEndpoint = $"{baseUri}/sharing/rest/oauth2/token";
    options.UserInformationEndpoint = $"{baseUri}/sharing/rest/community/self";
    options.SaveTokens = true;
});

builder.Services.AddAuthorization(options =>
{
   options.FallbackPolicy = new AuthorizationPolicyBuilder()
   .RequireAuthenticatedUser()
   .Build();
});

The below is not final code. It's in progress until I find a solution to this problem at which point it'll be cleaned up and perhaps implemented entirely differently.

// AccountController.cs
// https://stackoverflow.com/questions/53654020/how-to-implement-google-login-in-net-core-without-an-entityframework-provider
public class AccountController : Controller
{
    private readonly string _portalUrl;
    private readonly OAuthOptions _oAuthOptions;

    public AccountController(
        IOptionsSnapshot<OAuthOptions> oAuthOptions,
        IOptionsSnapshot<PortalUrlOptions> portalUrlOptions
    )
    {
        _oAuthOptions = oAuthOptions.Value;
        _portalUrl = portalUrlOptions.Value.PortalUrl;
    }

    [Route("/login")]
    [AllowAnonymous]
    public IActionResult LogIn(string returnUrl)
    {
        var props = new AuthenticationProperties { RedirectUri = returnUrl ?? "/" };
        return new ChallengeResult("ArcGIS", props);
    }

    [Route("/logout")]
    public async Task<IActionResult> LogOut()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

        var content = BuildPageContent(HttpContext);
        return Content(content, "text/html"); // Redirect(redirTo);
    }

    private static string BuildPageContent (HttpContext context)
    {
        var saml = BuildSamlRequest(context);

        var cookies = context.Request.Cookies;
        var relayState = cookies.ContainsKey("RelayState") 
            ? cookies["RelayState"] 
            : "";

        var signOutPage = $@"<!DOCTYPE html>
        <html lang=""en"">
          <head>
            <meta charset=""UTF-8"" />
            <meta name=""viewport"" content=""width=device-width, initial-scale=1.0"" />
            <title>Signing out</title>
            <script language=""javascript"">
              window.onload = function (e) {{
                document.forms[0].submit();
              }};
            </script>
          </head>
          <body>
            Bye bye bye!
            <form name=""f"" method=""post"" action=""https://login.microsoftonline.us/{tenant}/saml2"">
                <input type=""hidden"" name=""SAMLRequest"" value=""{saml}""/>
                <input type=""hidden"" name=""post_logout_redirect_uri"" value=""https://localhost/app""/>
            <form>
          </body>
        </html>
        ";

        return signOutPage;
    }

   

    private static string BuildSamlRequest(HttpContext context)
    {
        // https://learn.microsoft.com/en-us/entra/identity-platform/single-sign-out-saml-protocol#logoutrequest
        string id = $"id{Guid.NewGuid()}"; // id must not begin with a number 
        string instant = DateTime.Now.ToUniversalTime().ToString("o"); // round trip format
        string destination = "https://login.microsoftonline.us/{tenant}";
        string issuer = "the_issuer";
        string email = context.User.FindFirst(ClaimTypes.Email).Value;

        string request = $@"
            <samlp:LogoutRequest 
                    xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol"" 
                    xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion"" 
                    Version=""2.0"" 
                    ID=""{id}"" 
                    IssueInstant=""{instant}"" 
                    Destination=""{destination}"">
                <saml:Issuer>{issuer}</saml:Issuer>
                <saml:NameID>{email}</saml:NameID>
            </samlp:LogoutRequest>";

        var bytes = Encoding.UTF8.GetBytes(request);
        return System.Convert.ToBase64String(bytes);
    }
    
}

Upvotes: 0

Views: 139

Answers (1)

Tore Nestenius
Tore Nestenius

Reputation: 19961

You can specify a redirectURI when you do a signout, like this:

[AllowAnonymous]
[HttpPost]
public async Task Logout()
{
    await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme,
                new AuthenticationProperties { RedirectUri = "/" });

}

Sometimes, this URL also have to be registered in the Oauth server.

If you are curious about state/nonce parameters and how they work,then I got a blog post about that here:

Upvotes: 0

Related Questions