You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Sustainsys.Saml2用app.Map()实现多租户时ACS端点响应接收异常

多租户Saml2集成ACS端点无法接收响应问题

问题描述

现有多租户应用支持每个租户使用独立IdP完成身份认证,当前代码可正常跳转至对应IdP认证页,但认证完成后ACS端点无法接收到IdP返回的认证响应。
核心配置在Startup的Configuration方法中,通过Owin的Map方法按租户划分路由分支,每个分支独立配置对应IdP的Saml2认证逻辑:

[assembly: OwinStartup(typeof(SSOSamlDemoASPNET.App_Start.Startup))]
namespace SSOSamlDemoASPNET.App_Start
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            app.Map("/client/okta", (appx) =>
            {
                ConfigureAuthentication(appx, "/client/okta/Saml2", ...);
            });

            app.Map("/client/azuread", (appx) =>
            {
                ConfigureAuthentication(appx, "/client/azuread/Saml2", ...);
            });
        }

        private static void ConfigureAuthentication(IAppBuilder app, string modulePath, string audience, string issuer, string metadataUrl)
        {
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = CookieAuthenticationDefaults.AuthenticationType,
                CookieName = "LoggedUser",
                CookiePath = "/",
                CookieManager = new SystemWebCookieManager(),
            });

            app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);

            ConfigureSaml(app, modulePath, audience, issuer, metadataUrl);
        }

        private static void ConfigureSaml(IAppBuilder app, string modulePath, string audience, string issuer, string metadataUrl)
        {
            var saml2options = new Saml2AuthenticationOptions(false);
            var spOptions = new SPOptions
            {
                EntityId = new EntityId(audience),
                ModulePath = modulePath,
                PublicOrigin = new Uri("https://localhost:44340/"),
            };
            spOptions.Logger = new ConsoleLoggerAdapter();
            saml2options.SPOptions = spOptions;

            saml2options.IdentityProviders.Add(new IdentityProvider(new EntityId(issuer), spOptions)
            {
                AllowUnsolicitedAuthnResponse = true,
                MetadataLocation = metadataUrl,
                LoadMetadata = true,
                Binding = Saml2BindingType.HttpPost,
            });

            app.UseSaml2Authentication(saml2options);
        }
    }
}

单个IdP的认证发起逻辑如下:

authProperties.Dictionary["idp"] = "https://sts.windows.net/xxx/";
authProperties.RedirectUri = "https://localhost:44340/client/azuread/ExternalLoginCallback";
HttpContext.Current.Request.GetOwinContext().Authentication.Challenge(authProperties, "Saml2");

根因定位

排查Sustainsys.Saml2库源码中Saml2AuthenticationHandler的实现后确认,问题出在路径匹配逻辑未适配Owin Map的路径处理规则:Owin执行Map分支时,会把匹配到的路径前缀从Request.Path移动到Request.PathBase属性中,但Saml2中间件的路径判断逻辑只读取Request.Path做前缀匹配,完全忽略PathBase,导致ACS请求无法命中处理逻辑。
实际出问题时的参数值:

  • 代码中配置的Options.SPOptions.ModulePath = /client/azuread/Saml2
  • 请求进入Map分支后,/client/azuread前缀被移到PathBase,剩余Request.Path = /Saml2/Acs
    路径匹配时拿/Saml2/Acs去匹配前缀/client/azuread/Saml2,自然匹配失败,ACS处理逻辑不会执行。
    对应源码逻辑片段如下:
public override async Task<bool> InvokeAsync()
{
    var Saml2Path = new PathString(Options.SPOptions.ModulePath);

    if (Request.Path.StartsWithSegments(Saml2Path, out PathString remainingPath))
    {
        if (remainingPath == new PathString("/" + CommandFactory.AcsCommandName))
        {
            var ticket = (MultipleIdentityAuthenticationTicket)await AuthenticateAsync();
            if (ticket.Identities.Any())
            {
                Context.Authentication.SignIn(ticket.Properties, ticket.Identities.ToArray());
            }
            else
            {
                Response.Redirect(ticket.Properties.RedirectUri);
            }
            return true;
        }
    }
}

解决方案

方案1:调整ModulePath为分支内相对路径(无侵入,推荐)

这是最简单的适配方式,完全遵循Owin Map的路径规则,不需要修改库代码或添加自定义拦截逻辑:
在Map分支内部配置Saml2时,ModulePath只需要传分支内的相对路径,不要带Map的前缀路径。修改配置如下:

app.Map("/client/okta", (appx) =>
{
    // 分支内ModulePath配置为/Saml2,不带/client/okta前缀
    ConfigureAuthentication(appx, "/Saml2", ...);
});

app.Map("/client/azuread", (appx) =>
{
    // 分支内ModulePath配置为/Saml2,不带/client/azuread前缀
    ConfigureAuthentication(appx, "/Saml2", ...);
});

修改后Saml2中间件在分支内匹配的路径前缀就是/Saml2,和Map切分后的Request.Path完全匹配,ACS请求可以正常命中处理逻辑。库会自动结合PublicOrigin和PathBase生成完整的ACS地址返回给IdP,不需要手动拼接全路径。
额外优化建议:把CookieAuthentication中间件的注册移到Map外层的根管道上,不要在每个租户分支重复注册,避免多分支Cookie加密隔离导致登录状态异常。

方案2:通过Notifications自定义路径修正逻辑

如果因为业务约束不能调整ModulePath配置,可以通过Saml2Options提供的Notifications扩展点,在请求进入Saml2中间件前手动修正路径参数,让内置匹配逻辑正常工作:

private static void ConfigureSaml(IAppBuilder app, string modulePath, string audience, string issuer, string metadataUrl)
{
    var saml2options = new Saml2AuthenticationOptions(false);
    // 省略原有SPOptions和IdP配置逻辑...
    
    saml2options.Notifications = new Saml2Notifications
    {
        MessageReceived = context =>
        {
            var request = context.OwinContext.Request;
            var fullRequestPath = request.PathBase + request.Path;
            var expectedAcsFullPath = new PathString(modulePath + "/Acs");
            
            if (fullRequestPath == expectedAcsFullPath)
            {
                // 重置Path为全路径,清空PathBase,让内置路径匹配逻辑命中
                request.Path = fullRequestPath;
                request.PathBase = PathString.Empty;
            }
            return Task.CompletedTask;
        }
    };

    app.UseSaml2Authentication(saml2options);
}

内容的提问来源于stack exchange,提问作者Pavel Sem

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 09:00:52