ASP.NET Core登出重定向至signin-arcgis引发OAuth state无效错误
我们有一个基于SPA+ASP.NET Core 6的Web应用,使用通过SAML配置Microsoft Entra ID的ArcGIS Portal进行认证,ASP.NET Core借助AspNet.Security.OAuth.Providers中的ArcGIS提供程序处理登录与登出流程。需求是登出应用时同时登出身份提供商(IdP),但目前遇到以下问题:
- 登出时会错误重定向至内部中间件路由
app/signin-arcgis,抛出「The oauth state was missing or invalid」错误。 - 用户完成登录后会被重定向至
app/error而非app/index.html,需手动移除/error路径才能访问应用。
具体登出流程
- 用户点击“登出”
- 重定向至
app/logout - 服务端调用
HttpContext.SignoutAsync删除自身认证Cookie - 客户端提交隐藏表单向IdP(
https://login.microsoftonline.us/{tenant}/saml2)发送LogoutRequest - IdP完成登出后重定向至
/portal/sharing/rest/oauth2/saml/signout - 该页面删除Portal Cookie后返回Location头为
app/signin-arcgis - 用户无参数重定向至
app/signin-arcgis,引发上述错误 - 后续被重定向至
app/login?ReturnUrl=%2Fapp%2Ferror,登录后仍跳转至app/error
疑问
- 为何会重定向至
app/signin-arcgis? - 如何修改为重定向至
app/index.html? - 实现该需求是否有更优方案?
附代码
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(); });
AccountController.cs
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) { 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); } }
1. 为何会重定向至app/signin-arcgis?
这是因为ArcGIS Portal的SAML登出页面(/portal/sharing/rest/oauth2/saml/signout)默认会重定向到OAuth认证的回调地址,也就是你配置的ArcGIS OAuth提供程序的默认回调路径signin-arcgis。当IdP完成登出后跳转到Portal的这个登出端点,它会自动触发这个默认重定向,而此时你的应用已经清除了认证Cookie,且没有携带OAuth所需的state参数,所以访问signin-arcgis时会抛出「The oauth state was missing or invalid」错误。
另外,登录后跳转到app/error是因为登出流程中产生的错误路径被作为ReturnUrl传递给了登录接口,登录完成后会自动跳转到这个错误的ReturnUrl。
2. 如何修改为重定向至app/index.html?
需要从三个关键环节调整:
(1)修改ArcGIS Portal的登出重定向目标
在ArcGIS Portal管理界面的SAML认证设置中,找到“登出URL”配置项,将其设置为你的应用首页https://localhost/app/index.html。这样Portal的/portal/sharing/rest/oauth2/saml/signout页面处理完登出操作后,会直接跳转到指定的首页,而非默认的signin-arcgis。
(2)过滤无效的ReturnUrl
在登录接口中,拦截包含/error的无效ReturnUrl,强制替换为应用首页:
[Route("/login")] [AllowAnonymous] public IActionResult LogIn(string returnUrl) { var validReturnUrl = !string.IsNullOrEmpty(returnUrl) && !returnUrl.Contains("/error") ? returnUrl : "/app/index.html"; var props = new AuthenticationProperties { RedirectUri = validReturnUrl }; return new ChallengeResult("ArcGIS", props); }
(3)优化SAML登出请求的重定向参数
修改BuildPageContent中的post_logout_redirect_uri,使其指向Portal的SAML登出端点,而非直接指向应用:
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=""{_portalUrl}/sharing/rest/oauth2/saml/signout""/> </form> </body> </html>";
3. 实现该需求是否有更优方案?
有两种更简洁可靠的方案:
方案一:直接对接Entra ID的SAML服务
移除AspNet.Security.OAuth.Providers的ArcGIS提供程序,改用ITfoxtec.Identity.Saml2.MvcCore这类SAML中间件直接对接Microsoft Entra ID。利用中间件自带的单点登出功能,只需配置SignedOutCallbackPath和PostLogoutRedirectUri,即可实现登出后自动跳转首页,无需手动构建SAML请求。
方案二:使用ArcGIS Portal的OAuth登出端点
放弃手动构建SAML请求,直接调用ArcGIS Portal的OAuth登出端点,并指定最终重定向地址:
[Route("/logout")] public async Task<IActionResult> LogOut() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); var logoutUrl = $"{_portalUrl}/sharing/rest/oauth2/signout?redirect_uri=https://localhost/app/index.html"; return Redirect(logoutUrl); }
这种方式由Portal负责与Entra ID的SAML登出交互,流程更简洁,且能直接控制最终的重定向目标。
内容的提问来源于stack exchange,提问作者Ryan Taylor

