Reputation: 8892
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...
app/signin-arcgis
?app/index.html
?More details below, including the code, at bottom.
Sign out flow
app/logout
app/logout
(server-side) calls HttpContext.SignoutAsync
to delete own authentication cookieapp/logout
(client-side) submits a hidden form to POST a LogoutRequest to the IdP (https://login.microsoftonline.us/{tenant}/saml2)/portal/sharing/rest/oauth2/saml/signout
app/signin-arcgis
app/signin-arcgis
with no parameters
app/signin-arcgis
(server-side) throws the "The oauth state was missing or invalid" errorapp/signin-arcgis
returns a Location header value of app/login?ReturnUrl=%2Fapp%2Ferror
(our global error handler page)app/login?ReturnUrl=%2Fapp%2Ferror
to begin a new auth challenge (as all routes are require authenticated access by default/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
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